跳转至

第 10 章:上下文与事件——让 Agent 在有限窗口里继续工作

一、上下文问题有两层

Ollama 的上下文管理不是只有“超长就截断”:

  1. 输入预处理:本次请求发送给模型前,工具结果可能已经过预算截断。
  2. 历史压缩:当累计消息超过 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 层再校验。