Files
NotesAgentic/docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md
T

458 lines
18 KiB
Markdown
Raw 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.
# Agent Core 第二阶段:Trace 持久化与 SSE 恢复问题复盘
> 审阅与修复日期:2026-09-01
> 涉及分支:`feat/agent-trace-persistence`
> 功能提交:`3cb197a feat(agent): 持久化Trace并支持SSE恢复`
> 文档用途:记录 Agent Run、Trace、SSE 恢复和审计数据安全问题的形成原因、实际后果、解决思路与落地方案,供后续开发文档、比赛材料和技术博客写作使用。
## 1. 背景与结论
第一阶段 Agent Runtime 已经能够完成模型调用、Tool Calling、权限确认、取消、Usage 和 Citation,但 Run 与 Event 仍以进程内字典和列表为事实来源。第一阶段审阅加入的 Run/Event 数量上限解决了内存无界增长,却没有解决重启丢失、断线续传、Benchmark 复用和敏感数据审计等第二阶段问题。
本轮处理了 8 类问题:
| 编号 | 问题 | 级别 | 处理结果 |
| --- | --- | --- | --- |
| A-01 | Agent Run 与 Event 只存在于进程内存 | P0 | 增加 SQLite v3 Schema 和 Trace Repository |
| A-02 | Event 裁剪后 sequence 可能重复 | P0 | 改为独立单调序号并建立数据库幂等键 |
| A-03 | SSE 断线后无法从指定事件恢复 | P1 | 支持 `Last-Event-ID``after_sequence` 和 SSE `id` |
| A-04 | 缺少可分页 Trace 与 Benchmark 配置快照 | P1 | 增加 Trace API、统计摘要与配置快照 |
| A-05 | Trace 缺少模型调用、父子关系和权限结果 | P1 | 增加第二阶段事件及耗时/parent 字段 |
| A-06 | Trace 可能保存 Secret 和超大 Tool Result | P0 | Run、Request、Config、Event 统一脱敏与截断 |
| A-07 | 进程重启后未终止 Run 永久显示运行中 | P1 | 自动收束为 `AGENT_PROCESS_RESTARTED` |
| A-08 | 前端 DTO 与 SSE Client 无法消费恢复协议 | P1 | 同步 TypeScript Contract、Service、标签和 SSE id |
修复后的验证基线为:后端 80 项测试、前端 27 项测试、TypeScript 类型检查和生产构建通过。
## 2. A-01Agent Run 与 Event 只存在于进程内存
### 原因
`AgentRuntime` 使用 `_records: dict[str, RunRecord]` 保存全部运行状态。每个 `RunRecord` 内部再保存 `events`、实时订阅队列和异步任务。创建、查询、列表和 SSE 回放都只读取这个字典,没有 Repository 边界。
第一阶段为控制内存加入了最多 200 个 Run、每个 Run 2000 个 Event 的限制。这是必要的资源保护,但只是减少进程内数据量,不能替代持久化。
### 后果
- AI Core 重启后,历史 Run、Tool Result、Citation 和错误全部丢失;
- 前端刷新或重新连接后,只能读取当前进程尚未淘汰的数据;
- Agent Benchmark 无法使用稳定的历史事实计算指标;
- Run 列表随着进程重启清空,界面记录和实际操作脱节;
- 内存裁剪后的旧事件无法再恢复。
### 解决思路
将 SQLite 中的 Run/Event 作为唯一持久化事实,把内存降级为执行上下文和实时订阅窗口。Runtime 继续负责状态机,Repository 负责落库、分页、恢复和统计,Router 不直接访问数据库。
### 解决方案
新增 SQLite v3 migration
```text
agent_runs
├── run_id
├── status
├── run_json
├── request_json
├── config_snapshot_json
├── created_at
└── updated_at
agent_events
├── run_id
├── sequence
├── event
├── data_json
└── timestamp
```
新增 `AgentTraceRepository`,负责:
- 创建 Run 及请求、配置快照;
- 在同一事务中更新 Run 并追加 Event;
- 按创建时间分页列出 Run
- 按 sequence 读取 Event
- 生成 Trace 分页响应和统计摘要;
- 恢复进程中断的非终态 Run。
内存中的 2000 条 Event 上限继续保留,但只用于实时订阅窗口;完整记录由 SQLite 管理。
## 3. A-02Event 裁剪后 sequence 可能重复
### 原因
`_publish()` 使用下面的方式生成序号:
```python
sequence = len(record.events)
```
当事件数量超过 2000 后,Runtime 会删除列表头部。列表长度重新回到 2000,后续事件仍会得到 2000,形成重复 sequence。
### 后果
- `run_id + sequence` 无法作为幂等键;
- 前端按 sequence 去重时会错误丢弃新事件;
- 时间线排序出现同序号节点;
- 持久化后会触发主键冲突,或者在错误的覆盖策略下破坏旧事件;
- Benchmark 无法可靠还原 Tool Call 顺序。
### 解决思路
sequence 应属于 Run 的逻辑时钟,不能从当前缓存长度推导。内存是否裁剪不得影响序号。
### 解决方案
- `RunRecord` 增加独立的 `next_sequence`
- 每次发布读取当前值,再原子递增;
- `agent_events` 使用 `(run_id, sequence)` 复合主键;
- Repository 对重复写入使用幂等插入,不覆盖已经存在的事件事实;
- Trace 和 SSE 均严格按 sequence 升序返回。
## 4. A-03SSE 断线后无法恢复
### 原因
旧 SSE 接口只能从内存列表头部重新回放全部历史,再切换到实时队列。协议帧只有 `event:``data:`,没有 SSE 标准的 `id:`。接口也不读取 `Last-Event-ID` 或查询游标。
前端即使知道自己最后处理到哪个 sequence,也无法把该位置传回服务端。
### 后果
- 短暂断网或页面切换后只能从头回放;
- 长 Run 重连会重复传输大量事件;
- 前端需要依赖本地去重掩盖服务端缺少恢复能力;
- AI Core 重启后无法续传,因为历史事件本身也不存在;
- 实时与历史交界处容易漏事件或重复事件。
### 解决思路
恢复协议以 sequence 为游标。服务端先注册实时订阅,再读取 `sequence > cursor` 的持久化历史,随后消费实时队列;交界处允许重复,但 Runtime 和前端都按 sequence 去重。
### 解决方案
接口支持两种游标输入:
```http
GET /api/agent/runs/{run_id}/events?after_sequence=42
Last-Event-ID: 42
```
返回帧包含:
```text
id: 43
event: ToolResult
data: {"run_id":"...","sequence":43,"data":{...}}
```
具体处理:
- `after_sequence` 优先于 `Last-Event-ID`
- 默认游标为 `-1`,表示从 sequence 0 开始;
- 非整数或小于 `-1` 的 Header 返回 `TRACE_CURSOR_INVALID`
- 历史回放读取 SQLite,不依赖内存窗口;
- 历史与实时交界处按最后已发送 sequence 跳过重复项;
- 终态 Run 回放完终止事件后关闭连接。
## 5. A-04:缺少可分页 Trace 与 Benchmark 配置快照
### 原因
旧接口只有 Run 状态和 SSE。SSE 适合实时消费,不适合报告页随机访问、大 Trace 分页或 Benchmark 批量计算。Run 也没有保存创建时的 Provider、Model、Skill、允许工具和限制参数快照。
### 后果
- Trace 页面只能依赖一次长连接重建全部状态;
- Benchmark 需要绕过正式接口读取 Runtime 内部对象;
- Provider 或 Skill 配置变化后,旧结果失去可解释性;
- 大 Trace 无法受控分页,接口响应体会持续增大;
- 前端和 Benchmark 容易各自实现一套不一致的统计逻辑。
### 解决思路
提供面向读取的 Trace Snapshot API,但只返回平铺事实,不在后端生成前端树形布局。前端按 parent ID 和 sequence 构造时间线,Benchmark 从同一事件计算指标。
### 解决方案
新增接口:
```http
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
```
响应包含:
```json
{
"run_id": "run_123",
"status": "completed",
"items": [],
"next_sequence": 199,
"has_more": true,
"summary": {
"model_calls": 2,
"tool_calls": 3,
"duration_ms": 1530,
"token_usage": 2048,
"errors": 0
},
"config_snapshot": {}
}
```
配置快照保存:
- Provider ID 和类型;
- Model
- Capability
- Skill ID
- 允许的 Tool
- `max_steps`、Token Budget 和网络权限;
- 经过脱敏的 Metadata。
## 6. A-05Trace 缺少关键执行事实
### 原因
第一阶段事件能够表达 Run、Tool、Permission Request、Usage 和 Citation,但没有明确表示一次模型调用的开始、完成或失败。Tool Call 与模型轮次之间也没有 parent ID,Tool Result 缺少统一耗时。权限接口只唤醒 Future,不记录最终决定。
### 后果
- 前端无法展示“模型调用 → 多个 Tool → 下一次模型调用”的完整树;
- Agent Benchmark 无法计算模型调用次数和平均步骤耗时;
- 并发 Tool Call 时难以判断属于哪个模型轮次;
- 权限卡片消失后,Trace 中只保留“请求过权限”,不知道用户允许还是拒绝;
- Provider 失败只能看到最终 RunFailed,缺少模型调用级上下文。
### 解决思路
保持现有 AgentEvent envelope 不变,只增加事件类型和可选数据字段。调用关系使用稳定 ID 表达,不让前端根据相邻位置猜测父子关系。
### 解决方案
新增事件:
```text
ModelCallStarted
ModelCallCompleted
ModelCallFailed
PermissionResolved
```
补充字段:
- Model Call`model_call_id`、Provider、Model、Step、Finish Reason、Token、Duration
- Tool Call/Result`parent_model_call_id``duration_ms`
- Permission Resolved`request_id`、Permission、Decision
- Model Call Failed`error_code` 和耗时,不写入第三方原始敏感异常。
模型与 Tool 耗时使用单调时钟计算,避免系统时间调整影响 Duration。
## 7. A-06Trace 可能保存 Secret 和超大结果
### 原因
Tool 参数和输出来自模型、插件或外部服务,属于不可信数据。旧事件直接保存 `ToolCall.model_dump()``ToolResult.model_dump()``notes.read`、附件或第三方 Tool 可以返回大段正文,参数也可能包含 `api_key`、Authorization 或 Password。
初版持久化修复只净化了 `agent_events.data_json`。提交前审阅发现,`agent_runs.run_json` 中的 `tool_results``output``input` 仍可能保存同一份敏感值,说明“只在事件层脱敏”并不完整。
### 后果
- API Key 或 Bearer Token 可能进入 SQLite、备份和测试产物;
- Trace API 不返回 Secret,但数据库中的 Run Snapshot 仍可能泄露;
- 单个 Tool Result 可以让 Event 和 Run JSON 快速膨胀;
- 前端展开节点时可能因为超大 JSON 卡顿;
- Benchmark Dataset 或报告导出可能间接携带密钥。
### 解决思路
所有进入持久化边界的数据统一经过同一个净化函数,不能分别在 Router、Runtime 和 Repository 中维护不同规则。净化必须同时覆盖键名、常见密钥值模式、递归深度、字符串长度和集合大小。
### 解决方案
统一处理以下对象:
```text
AgentRun Snapshot
AgentRunCreateRequest Snapshot
Config Snapshot
AgentEvent Data
```
净化规则:
- `api_key`、Authorization、Access/Refresh Token、Password、Secret 等键替换为 `[REDACTED]`
- 常见 `sk-...``Bearer ...` 字符串模式直接替换;
- 单字符串最多保留 4096 个字符;
- 单集合最多保留 100 项;
- 递归深度最多 8 层;
- 超限位置使用明确的 `[TRUNCATED]``[MAX_DEPTH]` 标记;
- `credential_id` 等非明文引用保留,不误判为 Secret。
回归测试直接读取 `agent_runs` 原始 SQLite 字段,确认测试密钥没有落盘,避免只验证 API 响应造成假安全。
## 8. A-07:重启后未终止 Run 永久显示运行中
### 原因
Agent 的异步 Task 和 Permission Future 不能跨进程恢复。持久化 Run 后,如果直接返回数据库状态,重启前处于 `queued``running``waiting_permission` 的记录会一直保持非终态,但新进程中没有对应 Task 可以继续执行。
### 后果
- 前端长期显示“运行中”或“等待授权”;
- SSE 订阅等待一个永远不会到来的终止事件;
- Benchmark Runner 无法判断 Case 已中断;
- 用户取消该 Run 时,新进程找不到实际 Task;
- 统计中的成功率和耗时被悬挂 Run 污染。
### 解决思路
本阶段提供“状态恢复”,不伪装成“执行恢复”。没有可重放状态机、Provider 幂等令牌和 Tool 副作用日志之前,自动继续执行会造成重复写入或重复网络请求。
### 解决方案
新 Runtime 首次读取不属于当前进程的非终态 Run 时:
- 状态改为 `failed`
- 错误码设为 `AGENT_PROCESS_RESTARTED`
- 错误信息说明进程在完成前重启;
- 使用数据库最大 sequence 加一,追加唯一 `RunFailed` 事件;
- 后续 Run 查询、Trace 和 SSE 都返回同一终态事实。
已经完成、失败或取消的 Run 不修改,可以在重启后继续查询和回放。
## 9. A-08:前端无法消费第二阶段恢复协议
### 原因
后端增加新事件和 SSE `id` 后,前端 `AgentEventType` 仍只包含第一阶段事件。`SseClient` 只解析 `event``data`,忽略 `id`,也没有发送 `Last-Event-ID`。因此仅完成后端并不能形成可联调的 Contract。
### 后果
- `Record<AgentEventType, string>` 中文标签无法通过类型检查;
- 前端不知道 Model Call 和 Permission Resolved 的类型;
- 断线后无法把最后事件 ID 传回后端;
- Trace 可视化负责人需要自行猜测 Wire DTO;
- 后端恢复能力只能通过 Curl 使用,页面调用链没有闭环。
### 解决思路
范侧只提供稳定的前端接口适配,不越过分工实现 Trace Visualization。Pydantic Contract、TypeScript Wire DTO 和 Service 必须在同一功能提交中同步。
### 解决方案
- TypeScript 增加四类第二阶段 Agent Event
- 增加 `AgentTraceSummary``AgentTraceResponse`
- `agentService.getAgentTrace()` 封装分页查询;
- `streamAgentEvents()` 接受 `afterSequence`
- `SseClient` 发送 `Last-Event-ID` 并解析返回帧的 `id`
- 新事件增加中文标签和详情字段名;
- 增加 SSE 请求头和 Event ID 单元测试。
Trace 时间线、树形布局、筛选、节点展开和 Citation 跳转仍由前端负责人实现。
## 10. 事务、顺序与恢复不变量
本轮修复明确了以下不变量:
1. `run_id + sequence` 唯一标识一条 Agent Event。
2. sequence 在一个 Run 内只增不减,不受内存裁剪影响。
3. 发布事件时,在同一 SQLite 事务中更新 Run Snapshot 并追加 Event。
4. SSE、Trace 页面和 Agent Benchmark 读取同一份 `agent_events`,不建立旁路。
5. 终止事件为 `RunCompleted``RunFailed``RunCancelled`;终态 Run 不再产生业务事件。
6. 重启后不能安全继续执行的 Run 必须明确失败,不能永久悬挂。
7. 所有持久化 Trace 数据先脱敏、再写入。
8. 前端按 `run_id + sequence` 去重,不能依赖一次网络读取对应一条 SSE Event。
## 11. 验证方法
后端:
```powershell
cd backend
uv run python -m compileall -q app
uv run pytest
```
前端:
```powershell
cd frontend
pnpm test
pnpm type-check
pnpm build
```
仓库检查:
```powershell
git diff --check
```
验证结果:
```text
backend pytest 80 passed
backend compileall passed
frontend vitest 11 files / 27 tests passed
frontend type-check passed
frontend build passed
git diff --check passed
```
本轮新增回归覆盖:
- 完成 Run 在新 Runtime 中恢复查询;
- Trace 多页读取和游标无重复;
- SSE `Last-Event-ID``after_sequence``id:` 帧;
- Model Call、Permission Resolved 和 Tool parent ID
- 进程中断 Run 自动生成唯一终止事件;
- API Key、Authorization 和超长 Tool 参数净化;
- 直接检查 SQLite,确认 Secret 未进入 Run/Request/Config Snapshot
- OpenAPI 发布 Trace 路径;
- 前端 SSE Client 发送和解析恢复游标。
测试仍会出现本机 `.pytest_cache` 无写入权限警告,不影响 80 项用例结果,也不涉及产品代码。
## 12. 当前边界与后续工作
### 12.1 本阶段明确不做
- 不在后端生成前端 Trace 树形布局;
- 不在进程重启后自动重放未完成 Tool 副作用;
- 不把 Secret 明文放入 Trace、日志或 Benchmark
- 不为 Benchmark 建立绕过 Agent Runtime 的专用执行协议。
### 12.2 后续需要继续处理
- 增加 Trace 保留、归档和被 Benchmark 引用时的保护策略;
- 引入保留窗口后实现 `TRACE_CURSOR_EXPIRED`
- 根据桌面网络策略增加有上限的指数退避自动重连;
- 评估高频 Token Event 的批量写入,减少 SQLite 连接与事务开销;
- Agent Benchmark 接入正式 Trace 并验证指标字段是否充足;
- 前端完成 Trace Timeline/Tree、筛选、节点详情和 Citation 跳转;
- 多进程或远程执行出现需求后,再设计带租约和幂等副作用的执行恢复。
## 13. 可复用经验
### 13.1 资源上限不等于持久化
限制内存 Run 和 Event 数量只能防止进程膨胀,不能解决重启、审计和报告复现。临时保护措施应在文档中明确标注,不能被误认为最终架构已经完成。
### 13.2 游标必须独立于缓存结构
只要 sequence 来源于 `len(list)`、数组下标或当前页位置,裁剪和分页就可能破坏唯一性。可恢复事件流必须使用独立、单调且可持久化的逻辑序号。
### 13.3 恢复读取不等于恢复执行
恢复 Run/Trace 查询相对安全;恢复一个包含 Tool 副作用的执行任务需要额外的幂等、租约和补偿机制。在没有这些机制时,明确失败比重复执行更可靠。
### 13.4 脱敏要覆盖全部持久化副本
同一敏感值可能同时出现在 Event、Run Snapshot、Request、Config、日志和报告中。只检查最终 API 响应无法证明数据没有落盘,安全测试应直接验证持久化介质。
### 13.5 生产者和消费者 Contract 必须同时更新
后端新增事件类型、字段或 SSE 规则时,至少同步 Pydantic、OpenAPI、TypeScript DTO、Service 和协议测试。可视化页面可以由另一成员开发,但不能让对方从后端实现反推 Contract。