122 lines
6.3 KiB
Markdown
122 lines
6.3 KiB
Markdown
# 01 运行控制:让一次请求有边界
|
||
|
||
这一篇只回答一个问题:**一次诊断请求由谁负责到底?**
|
||
|
||
## 1. 先看一个具体问题
|
||
|
||
用户发起支付超时诊断。请求开始后,Router 调了一次模型,Diagnosis Agent 又调用多轮模型和 Tool,SemanticGuard 最后还要调用一次模型。与此同时,客户端可能断开,某次调用可能超时,两个线程也可能同时报告成功和失败。
|
||
|
||
如果没有统一的运行控制,每个组件都会有自己的理解:
|
||
|
||
- Controller 认为连接断开了,后台 Agent 却还在继续查询;
|
||
- Agent 认为还可以调用 Tool,Run 的总预算其实已经耗尽;
|
||
- 超时线程先写入失败,迟到的模型结果又把它覆盖为成功;
|
||
- SDK 在内部偷偷重试,系统无法解释多出来的延迟和 Token;
|
||
- 线上只看到最终失败,却不知道请求在哪一步、因为什么停止。
|
||
|
||
所以运行控制不是一个计时器,而是由几个职责不同的组件共同完成。
|
||
|
||
## 2. 四组组件怎样协作
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant APP as Application
|
||
participant CORE as Core / RunContext
|
||
participant WORK as Agent、Tool、Guard
|
||
participant RETRY as Retry
|
||
participant AUDIT as Audit
|
||
|
||
APP->>CORE: 创建 RunContext
|
||
CORE-->>APP: runId、deadline、budget、cancel、lifecycle
|
||
APP->>WORK: 显式传入 RunContext
|
||
WORK->>CORE: 每次消耗前检查 active 和预算
|
||
WORK->>RETRY: 仅允许策略声明的技术重试
|
||
RETRY->>AUDIT: 记录每个 attempt
|
||
WORK->>AUDIT: 记录模型、Tool 和阶段元数据
|
||
APP->>CORE: 尝试写入最终终态
|
||
CORE-->>APP: first-terminal-wins
|
||
APP->>AUDIT: 记录公开结果和预算对账
|
||
```
|
||
|
||
第一次阅读可以把它们理解为:
|
||
|
||
- **Application 是负责人**:组织一次请求从创建到公开结果。
|
||
- **Core 是执行规则**:统一管理身份、deadline、预算、取消和唯一终态。
|
||
- **Retry 是重做规则**:明确什么失败允许再试一次。
|
||
- **Audit 是运行账本**:记录发生过什么,但不保存敏感正文。
|
||
|
||
## 3. Application:负责人,而不是推理者
|
||
|
||
Application 负责建立一次请求的应用边界:读取安全历史、创建 Run、判断 intent、调用对应执行分支、持久化结果,并把安全内容交给 SSE。
|
||
|
||
设计它的原因,是这些步骤必须由一个地方协调。如果散落在 Controller、Agent 和 Guard 中,会出现多个结果出口和不同的失败语义。
|
||
|
||
它不负责判断支付超时的根因,也不负责自己拼一份诊断 Fallback。它只负责把正确的执行组件按顺序接起来,并确保结果经过统一出口。
|
||
|
||
主要代码入口:`ChatApplicationUseCase`、`DiagnosisChatExecutor`、`KnowledgeQueryExecutor`、`SystemChatExecutor`、`ChatRunStore`。
|
||
|
||
## 4. Core:一次 Run 的共同规则
|
||
|
||
Core 创建 `RunContext`。这个上下文显式携带:
|
||
|
||
```text
|
||
这是谁的请求:sessionId + runId
|
||
最晚执行到何时:deadline
|
||
还能消耗多少:RunBudget
|
||
是否要求停止:RunCancellation
|
||
最终如何结束:RunLifecycle
|
||
模型消耗如何对账:ModelCallLedger
|
||
信息收集是否仍有价值:DiagnosisProgressTracker
|
||
```
|
||
|
||
这里最重要的决策是**显式传递 RunContext**,而不是使用 ThreadLocal 或让每个组件自己查询全局状态。原因是模型和 Tool 可能跨线程运行;隐式上下文很容易丢失、串线,也很难在测试中证明归属关系。
|
||
|
||
`RunLifecycle` 使用 first-terminal-wins:第一个成功写入的终态不可被迟到结果覆盖。它解决的是并发一致性,不是公开结果的业务含义。
|
||
|
||
主要代码入口:`DiagnosisHarnessCore`、`RunContext`、`RunBudget`、`RunCancellation`、`RunLifecycle`。
|
||
|
||
## 5. Retry:不能让“再试一次”藏起来
|
||
|
||
重试会增加成本和延迟,也可能重复副作用,因此不能由 SDK、HTTP Client 和各组件各自决定。当前策略是:
|
||
|
||
- Router 和 SemanticGuard 的特定技术失败最多尝试两次;
|
||
- Diagnosis Agent、业务 Tool 和 EvidenceRepair 不做隐藏自动重试;
|
||
- 取消、预算耗尽和协议错误不能被重试吞掉。
|
||
|
||
这里没有设计通用重试 DSL。首版只需要不可变的 `RetryPolicy`、稳定的失败分类和统一的 `HarnessRetryExecutor`,复杂配置系统反而会掩盖真实执行路径。
|
||
|
||
## 6. Audit:留下解释,而不是留下全部内容
|
||
|
||
线上排障需要知道:调用了哪个组件、用了多少 Token、Tool 是否完成、为什么停止、为什么发布 Fallback。但这不等于长期保存 Prompt、SQL、日志原文、Tool raw response 和模型 reasoning。
|
||
|
||
因此 Audit 长期保留的是有界元数据,完整 Tool 真相只在 canonical store 中短期存在。这个决策同时满足可回放和最小暴露原则。
|
||
|
||
主要代码入口:`DiagnosisTraceRecorder`、`TraceAuditEvents`、`ToolInvocationAuditSink`、`HarnessAgentAuditHook`、`ModelCallAuditor`。
|
||
|
||
## 7. 为什么没有合并成一个 RunManager
|
||
|
||
把上述职责都放进一个 `RunManager` 看起来更简单,但它会同时知道 HTTP、路由、预算、模型、Tool、数据库和 SSE,最后变成新的业务工作流引擎。
|
||
|
||
当前拆分依据不是“代码越细越好”,而是决策权不同:
|
||
|
||
| 组件 | 它拥有的决定 | 它不能决定 |
|
||
|---|---|---|
|
||
| Application | 请求走哪条应用分支、何时持久化和返回 | 业务根因是否成立 |
|
||
| Core | 是否仍可执行、资源是否允许、哪个终态生效 | 返回正常报告还是 Fallback |
|
||
| Retry | 某类技术失败是否允许下一 attempt | 修改业务结果 |
|
||
| Audit | 记录哪些安全元数据 | 影响执行结果 |
|
||
|
||
## 8. 先记住这些
|
||
|
||
第一次阅读只需记住:
|
||
|
||
1. Application 对一次请求负责,Core 对执行不变量负责。
|
||
2. RunContext 必须显式传播,所有模型和 Tool 共用同一预算与取消信号。
|
||
3. 第一个终态获胜,迟到结果不能翻案。
|
||
4. Retry 必须显式、分类、可审计。
|
||
5. Audit 记录控制事实,不长期复制敏感正文。
|
||
|
||
下一篇:[02 Agent 接入与收敛](02-Agent接入与收敛-让推理可以工作也可以停止.md)。
|
||
|
||
需要查完整状态转换时,阅读[生命周期与状态](../Harness生命周期与状态.md);需要查全部类型时,阅读[组件全景](../Harness组件全景-职责-设计原因与边界.md)。
|