8.3 KiB
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
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
- 本阶段新增零消费者 Core 类型和 tests,同时把 Spring AI retry 压为一次。
- 阶段 3A 的 Tool interceptor/store 显式接收 RunContext,复用 Key/容量门禁。
- 阶段 4/5 的 Agent/Guard 使用 Core model/retry/deadline 边界。
- 阶段 6A 的 Chat application use case 创建 RunContext 并映射终态到数据库。
- 阶段 6B 切换入口;阶段 7 删除 ThreadLocal 和旧重试循环。
回滚本阶段代码可删除新包;spring.ai.retry.max-attempts 若回滚到默认 10 会恢复隐藏重试,但会再次失去 attempt 可观测性,因此只允许在整体重构回滚时明确执行。
Open Questions
无。具体预算数值、Run ID 格式和持久化状态映射由后续 wiring/应用用例在不改变本 Core 语义的前提下配置。