第 10 章:上下文与事件——让 Agent 在有限窗口里继续工作¶
一、上下文问题有两层¶
Ollama 的上下文管理不是只有“超长就截断”:
- 输入预处理:本次请求发送给模型前,工具结果可能已经过预算截断。
- 历史压缩:当累计消息超过 context threshold,需要用 compactor 生成摘要,再继续运行。
两层必须配合,否则会出现“摘要成功了,但本轮 assistant tool call 与 tool result 被拆开”的非法历史。
二、Compactor 的契约¶
agent.Compactor 至少需要回答:
- 当前 context window 是多少。
- 什么时候
ShouldCompact。 - 如何
MaybeCompact并返回新的 message 列表/摘要。 - 使用什么 trigger:自动、tool output overflow 或用户触发。
Session 会在模型步骤前后检查预算;tool result 过大时还会走专门的 compactForToolOutputOverflow。
messages + latest response
→ 估算 tokens
→ 未超阈值:继续
→ 超阈值:compaction started event
→ 保留最近 suffix + 生成 summary
→ 重新拼接 tool call/result 配对
→ compacted event
→ 再次运行模型
三、为什么“保留后缀”¶
最近消息通常包含当前任务的局部状态,例如刚刚打开的文件、未完成的工具调用和用户最后一次澄清。压缩器会保留一个 suffix,将更早历史变成 summary。
设计目标不是让摘要“像全文一样完整”,而是保证:
- 最近任务可继续。
- tool call 与 result 的结构不破坏。
- 模型知道早期工作做过什么。
- 压缩后仍不超过窗口;若仍超,则明确失败而不是无限重试。
四、Tool output overflow 的特殊处理¶
工具结果有一个危险特征:它可能在模型还没来得及看到前,就把上下文撑爆。Session 因此会先对工具结果做预算级截断,再决定是否 compact;如果某个结果完全被省略,压缩后还会根据 toolCallID 重新附着一个可解释的 omitted message。
这条路径的思考顺序是:
先保护请求合法性
→ 再尽量保留信息
→ 再压缩旧历史
→ 最后才报告无法继续
五、事件是可观察性协议¶
agent/events.go 定义了事件类型:message delta、thinking delta、tool call detected、tool started/finished、run finished、error、compaction started/progress/skipped/compacted 等。
type EventSink interface {
Emit(Event) error
}
Session 可向多个 sink 广播事件;测试 TestSessionEmitsToAllSinksAfterError 说明即使发生错误,也不能只通知第一个 sink 后停止传播。
六、事件为什么不等于日志¶
日志是给开发者排障的文本;事件是给 UI、宿主、审计和测试消费的结构化协议。比如:
| 事件 | UI 可能的表现 |
|---|---|
message_delta |
增量渲染回答 |
thinking_delta |
展示折叠的思考区 |
tool_started |
显示工具名、参数、工作目录 |
tool_finished |
显示成功/错误/取消 |
compaction_started |
显示“正在整理上下文” |
run_finished |
收起 spinner、记录状态 |
如果 UI 直接解析日志,就会被文本格式牵制;事件让显示层和编排层保持解耦。
七、取消和部分结果¶
Session 的测试专门覆盖:
- streaming assistant message 收到一半后取消。
- UI sink 触发取消。
- tool call 后取消。
- tool 执行中取消。
- HTTP context 的 canceled 字符串被正确识别。
这说明取消不是“返回 error 就完了”。运行时还需要把部分 assistant 内容、跳过的 tool message 或最终状态整理成一个可消费的结果,避免下一次运行历史结构不一致。
八、token 估算的现实主义¶
ApproximateTokens 是近似值,不是 tokenizer 的完整精确计数;这样可以在 Go 侧低成本做预算控制,而不用每次为大段工具输出启动 tokenizer runner。真正送给模型前,native runner 仍会进行自己的限制和校验。
九、设计取舍¶
- 先截断 tool output,再压缩历史:保护当前请求,避免无意义地让摘要吞掉全部窗口。
- 保留后缀 + 摘要:继续性与历史信息之间折中。
- 事件与日志分离:结构化消费与开发排障各自优化。
- 近似 token 估算:低延迟、可移植,但需要在最终 runner 层再校验。