数据截至 (上游 commit 3a4e2ae3eec0)
第 3 章 · 上下文工程三件套
这一章讲:上下文快满了怎么办、单个工具结果太大怎么办、以及怎么让 agent 知道「现在几点了、上下文还剩多少」。
3.1 三件套是什么
| 机制 | 解决什么 | 触发时机 | 入口 |
|---|---|---|---|
| 上下文压缩 | 历史太长 | 每次推理前检查 | compress_context |
| 工具结果卸载 | 单个结果太大 | 每次工具执行完 | _split_tool_result_for_compression |
| 运行时状态注入 | agent 不知道当前时间/任务/剩余空间 | 每次推理前 | _inject_runtime_state |
三者共用同一个 token 估算函数,也共用同一个「卸载到工作区」的出口。
3.2 token 怎么数
先说清楚基础:count_tokens(src/agentscope/model/_base.py:369)不是真的分词。它把所有文本拼起来,按 UTF-8 字节数除以 4(:429):
cnt += int(len(acc_text.encode("utf-8")) / 4 + 0.5)
多模态块按固定值算:每个 DataBlock 记 2000 token(_MULTIMODAL_DATA_BLOCK_TOKEN_ESTIMATE,:33)。注释解释了为什么不按 base64 字符串长度算——模型不是把 base64 当文本吃的,按路径字符串算又太少,所以取一个稳定的平估值。
工具的 JSON schema 也计入(:418-420)。子类可以覆盖这个方法接真实 tokenizer。
对中文,字节除以 4 会显著高估(一个汉字 3 字节 ≈ 0.75 token,实际常在 0.6 左右)。它是保守的,宁可早压缩不要撑爆。
3.3 上下文压缩
触发
ContextConfig(src/agentscope/agent/_config.py:51)两个比例:
模型上下文长度 = context_size
0 ────────────────── reserve_ratio(0.1) ──── trigger_ratio(0.8) ──── 0.9(上限) ── 1.0
│ │
压缩后保留这么多 超过这里就压缩
trigger_ratio 被限制在 ≤ 0.9(字段约束 gt=0, le=0.9,src/agentscope/agent/_config.py:57-60),为的是给压缩这次模型调用本身留位置。构造函数还额外校验 reserve_ratio < trigger_ratio(_validate_configs,src/agentscope/agent/_agent.py:219)。
压缩成什么
不是让模型「写个摘要」,而是要 求它填一个五字段的结构化表单(SummarySchema,src/agentscope/agent/_config.py:9):
| 字段 | 装什么 |
|---|---|
task_overview | 用户的核心诉求与验收标准 |
current_state | 已完成什么、动了哪些文件 |
important_discoveries | 技术约束、决策与理由、试过但失败的路 |
next_steps | 还要做什么、有什么阻塞 |
context_to_preserve | 用户偏好、承诺过的事 |
压缩提示词里有一段特别值得抄(:74-98)。核心要求是所有指代必须自解释:
- 时间:把「今天」「刚才」换算成绝对日期,因为摘要还会被再次摘要;
- 名字:用文件路径、符号名、PR 号、完整命令,不许写「那个文件」「上面提到的」;
- 在途工作:后台跑着的工具要记 id 和用途。
理由写在提示词里:"This summary may itself be summarized again later, and the conversation history it refers to will be gone." 摘要会被摘要,任何相对指代都会在第二轮丢失锚点。
切在哪:不能拆散工具调用对
这是压缩里最容易写错的地方。_split_context_for_compression(src/agentscope/agent/_agent.py:2685)分三步:
第一步,从尾往前找消息级切点。 累加 token,直到保留部分 够 reserve_ratio。
第二步,切点落在某条消息中间时,再往块级细切。 因为一次 reply 的所有内容都攒在同一条 AssistantMsg 里(见 append_context,src/agentscope/state/_state.py:267),这条消息可能本身就很大。
第三步,反复推移边界直到工具调用配对稳定。 这是关键循环(:2606-2630):
检查保留区里有没有「孤儿 tool_result」(有结果没有对应的调用)
├─ 没有 ─► 切点稳定,收工
└─ 有 ─► 把最靠后的孤儿也推进压缩区,回到检查
为什么要 while True 而不是一次修正?源码注释说明了:"Moving the boundary can bring another tool call into the compressed part while leaving its result reserved." 推移边界本身会制造新的孤儿,所以得迭代到不动点。
压缩自己也可能撑爆
压缩要调一次模型,输入是「系统提示 + 待压缩内容 + 压缩指令」。如果这一坨本身就超长呢?
源码给了一个降级路径(:537-584):先用 context_overflow 标记预判,真的失败了就从最旧的消息开始逐条丢,边丢边重新估算,直到降到 trigger_ratio 以下再试一次。
还有两个边界处理:
- 保留比例太大导致压缩区为空 → 把
reserve_ratio临时降为 0 重新切(:470-488)。 - 上下文为空但仍超阈值 → 说明系统提示词本身就超了,直接抛
RuntimeError给开发者(:446-456)。这是少数不容忍的错误。
应用变更时防中断
最后一步用了 asyncio.shield(:627-632):
apply_task = asyncio.create_task(_apply_change())
try:
await asyncio.shield(apply_task)
except asyncio.CancelledError:
await apply_task
raise
意思是:压缩结果的落地不可被打断。_apply_change(:601-625)内部依次做三件事——把待压缩内容 offload_context 落盘、清读缓存、替换 state.summary 与 state.context。不加保护就会出现「旧上下文已清空、新摘要还没写入」的撕裂状态。