Files

110 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
现有 Chat/AIOps 在进入 Agent 前自行生成 session/run 标识,通过多个 ThreadLocal 和 RunnableConfig metadata 混合传播,并在业务方法内直接处理 retry loop、运行持久化和成功/失败终态。`TokenTrackingChatModel` 也只把 total token 写到 ThreadLocal,无法在异步或并发边界可靠归属 Run。后续 Tool interceptor、ResultProjector、Diagnosis Agent、EvidenceGuard、SemanticGuard 和 Chat application use case 需要共同使用一个不依赖旧 ChatService 的显式执行上下文。
Spring AI 1.1.7 的全局 retry properties 前缀为 `spring.ai.retry`,默认 `maxAttempts=10`。ISS-014 已确认所有底层模型 attempt 必须压为一次,由 Harness 对 Router/SemanticGuard 的特定技术失败显式执行最多一次重试并记录每次 attempt。
## Goals / Non-Goals
**Goals:**
- 提供显式、结构不可变、可跨同步/异步边界传递的 RunContext。
- 在内存执行层统一 deadline、取消、预算和唯一终态语义。
- 提供无隐藏循环的类型化 retry policy/executor 和 attempt 记录。
- 提供阶段 3A 可直接复用的 Tool Call Key Factory 与单 Run 字节容量计数器。
- 关闭 Spring AI 默认隐藏重试,保证实际模型 attempt 可由 Harness 观察。
**Non-Goals:**
- 不接入 Controller、SSE、旧 ChatService、AiOpsService、ReactAgent 或现有 Tool。
- 不替换或删除旧 ThreadLocal;阶段 7 在新入口切换后清理。
- 不修改 `diagnosis_run` 实体、表结构或 Repository,不写数据库终态。
- 不实现 Redis invocation store、ToolInterceptor、ToolResultProjector 或 Tool Call ID 校验全套策略。
- 不实现定时调度器、线程中断、工作流引擎、动态配置中心、Retry DSL 或退避算法。
## Decisions
### 1. RunContext 是结构不可变的共享状态句柄集合
`RunContext` 使用 Java 17 record,字段固定为 `sessionId`、`runId`、`deadline`、`RunCancellation`、`RunBudget`、`HarnessRetryPolicies` 和 `RunLifecycle`。record 不提供 setter;同一 Run 的同步/异步消费者必须显式传递同一个 context,因此共享相同的取消、预算和生命周期句柄。
替代方案是把所有字段做成纯值并在每次变化时复制 context;这会造成并发分支状态分叉,无法保证唯一终态和原子预算,故不采用。ThreadLocal/RunnableConfig fallback 也不进入新 Core。
### 2. DiagnosisHarnessCore 是唯一执行门禁与状态转换入口
Core 由 `Clock`、Run ID supplier、最大 Run duration、显式 `RunBudgetLimits` 和 `HarnessRetryPolicies` 构造。它负责创建 context、在每个模型/Tool/容量边界检查 active/deadline、预留预算、记录 Token、显式取消以及完成成功/失败终态。
Core 不保存全局 Run map;生命周期完全随 RunContext 所有,避免跨请求泄漏。调用方可以传入既有 runId 进行确定性测试/恢复,但 Core 不生成 sessionId,也不修改业务持久化。
### 3. Deadline 与取消采用协作式、可观察语义
`RunCancellation` 用 atomic first-reason-wins 保存 `CLIENT_DISCONNECTED`、`USER_REQUESTED`、`DEADLINE_EXCEEDED`、`BUDGET_EXHAUSTED` 或 `INTERNAL_FAILURE`,并允许注册资源取消回调。Core 在每个受控边界比较 `Clock.instant()` 与 deadline;到达或超过 deadline 时先写 `TIMED_OUT` 终态,再触发取消信号并拒绝后续执行。
取消回调异常会记录并继续通知其他回调,不能阻止取消传播。此模型不承诺已进入的同步第三方调用立即停止;具体 HTTP/JDBC future 取消由后续 adapter 注册回调实现。
### 4. RunLifecycle 使用 first-terminal-wins
`RunLifecycle` 初始为 `RUNNING`,终态固定为 `SUCCESS`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`BUDGET_EXHAUSTED`。内部使用 atomic compare-and-set 保存唯一 `RunTermination(state, reason, completedAt)`;任何后续 finish 都返回 false 且不得覆盖首个终态。
这只是执行内存真理;阶段 6A 的应用用例负责把终态映射到 `diagnosis_run.status/release_outcome`。本阶段不创建第二套数据库状态机。
### 5. RunBudget 使用显式 limits 和一致性更新
`RunBudgetLimits` 必须由调用方显式提供,包含模型调用、总 Tool 调用、单 Tool 调用、input/output/total Token 和 max Run bytes,所有值必须为正。`RunBudget` 使用同步临界区保证总 Tool/单 Tool 计数和三类 Token 计数要么一致提交,要么明确抛出 `BudgetExceededException`;模型返回后的实际 Token 即使超限也会先记入 usage,再终止后续执行。
`RunCapacityCounter` 用 CAS 原子预留 UTF-8 JSON 字节,失败时不部分增长。它被 RunBudget 持有并可由阶段 3A 直接调用。Core 在预算失败时先写 `BUDGET_EXHAUSTED` 终态,再触发取消。
### 6. Retry policy 只描述 attempt 上限与类型
`RetryPolicy(maxAttempts, retryableFailures)` 只允许 1 或 2 attempts。`HarnessRetryPolicies.strict()` 固定:Router=2(timeout/transport/invalid output)、Diagnosis=1、Tool=1、SemanticGuard=2(timeout/transport/parse/schema)、EvidenceRepair=1。
`HarnessRetryExecutor` 在每次 attempt 前调用 Core active check,操作成功/失败都发送 `RetryAttempt` 给 recorder。只有 policy 允许且尚有 attempt 时继续;取消、deadline、预算、`NO_EVIDENCE`、业务拒绝和未知失败不得重试。Executor 不 sleep、不退避、不递归、不调用 Agent。
### 7. 底层 Spring AI retry 固定为一次
`application.yml` 设置 `spring.ai.retry.max-attempts: 1`,并通过直接读取 YAML 的单元测试锁定,避免启动完整外部基础设施。该配置会立即影响旧运行路径:瞬时模型错误不再由 SDK 隐式重试;这是保证 attempt 可观测的有意内部行为变化。
### 8. ToolCallKeyFactory 不拥有 ID
Factory 接收可配置前缀,验证 `runId/toolCallId` 为非空、安全长度和安全字符 segment 后构造 `{prefix}:{runId}:{toolCallId}`。它不生成、规范化或哈希框架 Tool Call ID,不访问 Redis。严格的 duplicate/cross-run invocation 语义属于阶段 3A store。
## Module Flow
```text
future Chat application use case
-> DiagnosisHarnessCore.startRun(...)
-> RunContext
-> RunCancellation
-> RunBudget -> RunCapacityCounter
-> HarnessRetryPolicies
-> RunLifecycle
-> future Model/Tool/Guard boundary explicitly receives RunContext
-> Core check/reserve/record/finish
-> HarnessRetryExecutor for allowed technical operations only
-> future application use case maps the one RunTermination to diagnosis_run
```
阶段 3A 直接依赖 RunContext、Core、Key Factory 和 capacity counter;它不需要调用旧 ChatService 或读取 ThreadLocal。
## Risks / Trade-offs
- [结构不可变但句柄可变容易被误用] -> 命名、Javadoc 和并发/异步测试明确同一 Run 必须共享同一 context 实例。
- [关闭 SDK retry 后旧路径瞬时失败率可能上升] -> 明确列为行为变化,配置测试锁定;后续 Router/SemanticGuard 只按已确认策略补回可观测重试。
- [实际 Token 在响应后才知道,可能超预算] -> usage 保留实际值并立即进入预算终态,不丢失消耗、不再执行下一步。
- [取消回调由 adapter 决定能否硬取消] -> Core 只承诺协作式取消和后续门禁,后续 JDBC/HTTP adapter 必须注册资源回调。
- [纯内存终态无法替代审计] -> 阶段 6A 持久化映射;本阶段 focused tests 只验证执行语义。
## Migration Plan
1. 本阶段新增零消费者 Core 类型和 tests,同时把 Spring AI retry 压为一次。
2. 阶段 3A 的 Tool interceptor/store 显式接收 RunContext,复用 Key/容量门禁。
3. 阶段 4/5 的 Agent/Guard 使用 Core model/retry/deadline 边界。
4. 阶段 6A 的 Chat application use case 创建 RunContext 并映射终态到数据库。
5. 阶段 6B 切换入口;阶段 7 删除 ThreadLocal 和旧重试循环。
回滚本阶段代码可删除新包;`spring.ai.retry.max-attempts` 若回滚到默认 10 会恢复隐藏重试,但会再次失去 attempt 可观测性,因此只允许在整体重构回滚时明确执行。
## Open Questions
无。具体预算数值、Run ID 格式和持久化状态映射由后续 wiring/应用用例在不改变本 Core 语义的前提下配置。