跳到主要内容

数据截至 (上游 commit 3a4e2ae3eec0)

第 3 章 · 上下文工程三件套

这一章讲:上下文快满了怎么办、单个工具结果太大怎么办、以及怎么让 agent 知道「现在几点了、上下文还剩多少」。


3.1 三件套是什么

机制解决什么触发时机入口
上下文压缩历史太长每次推理前检查compress_context
工具结果卸载单个结果太大每次工具执行完_split_tool_result_for_compression
运行时状态注入agent 不知道当前时间/任务/剩余空间每次推理前_inject_runtime_state

三者共用同一个 token 估算函数,也共用同一个「卸载到工作区」的出口。


3.2 token 怎么数

先说清楚基础:count_tokenssrc/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 上下文压缩

触发

ContextConfigsrc/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.9src/agentscope/agent/_config.py:57-60),为的是给压缩这次模型调用本身留位置。构造函数还额外校验 reserve_ratio < trigger_ratio_validate_configssrc/agentscope/agent/_agent.py:219)。

压缩成什么

不是让模型「写个摘要」,而是要求它填一个五字段的结构化表单SummarySchemasrc/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_compressionsrc/agentscope/agent/_agent.py:2685)分三步:

第一步,从尾往前找消息级切点。 累加 token,直到保留部分够 reserve_ratio

第二步,切点落在某条消息中间时,再往块级细切。 因为一次 reply 的所有内容都攒在同一条 AssistantMsg 里(见 append_contextsrc/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.summarystate.context。不加保护就会出现「旧上下文已清空、新摘要还没写入」的撕裂状态。


3.4 工具结果卸载

问题

Read 一个 5 万行的日志,结果直接进上下文,一发就把窗口占满。

做法

_split_tool_result_for_compressionsrc/agentscope/agent/_agent.py:2823)在结果写入上下文之前切:

工具结果 (n_tokens)

├─ ≤ tool_result_limit(默认 50000) ─► 原样保留

└─ 超了 ─► 按块二分找边界
├─ 保留区 ──► 写进上下文,尾部拼 <<<TRUNCATED>>> 提醒
└─ 卸载区 ──► offloader.offload_tool_result() 落成工作区文件

拼上去的提醒长这样(src/agentscope/agent/_agent.py:2443-2461):

<<<TRUNCATED>>>
<system-reminder>The remaining content has been omitted for limited context.
You can refer to the file in '<路径>' for the truncated content if needed.</system-reminder>

这是「上下文当内存、工作区当磁盘」的直接体现:内容没丢,只是换了个地方,并且给了模型取回它的地址。

边界块如果是文本,还会按 token 比例进一步截断(:2734-2763),而不是整块丢弃。

谁来存

Offloader 是个 Protocolsrc/agentscope/workspace/_offload_protocol.py:8),三个方法:offload_data_blockoffload_contextoffload_tool_result。任何 WorkspaceBase 都实现了它,所以 offloader=workspace 是最常见的写法。不给 offloader 就只截断不保存。


3.5 运行时状态注入

问题

模型不知道现在几点、不知道自己还有几个未完成任务、不知道上下文快满了。这些信息每轮都在变

为什么不写进系统提示词

因为会毁掉 prompt 缓存。源码注释直说(src/agentscope/agent/_agent.py:1208-1210):

We attach a HintBlock instead of mutating the system prompt, so that prompt caching still works while the agent remains aware of the changing time / tasks / context.

HintBlocksrc/agentscope/message/_block.py:101)是一种特殊内容块,格式化时会被转成一条 user 消息追加在末尾——前缀不动,缓存命中。

三个维度,各有各的触发条件

维度什么时候注入
时间上下文里没有记录过时间(首轮或刚压缩完),或距上次记录超过 time_interval 小时(默认 0.5)
计划任务有未完成任务,上下文里既没有任务类工具调用、也没有之前的任务注入
上下文用量只在一次 reply 的第 0 轮,且已进入压缩阈值前的缓冲区(默认阈值前 20%)

注入格式(模板见 src/agentscope/agent/_config.py:223-235,默认字符串在 :210-214):

<system-reminder>Treat the following as the ground truth at this point of the
conversation. Anything stated earlier is outdated, and a later reminder, if any,
supersedes this one:
<current-time>2026-08-18T09:30:00</current-time>
<timezone>Asia/Shanghai</timezone>
</system-reminder>

那句「后面的提醒覆盖前面的」很重要——注入是持久写进上下文的(不是临时的),所以历史里会有多条时间戳,必须告诉模型以最新的为准。模板本身还带一个校验器,模板里少了 {runtime_state} 占位符直接 raise ValueError:224-236),免得注入被静默丢掉。

时间注入的三个坑

源码里能看出踩过的坑:

  1. 时区会变。 时间旁边同时注入 <timezone>,比对时先按当时记录的时区还原(src/agentscope/agent/_agent.py:1336-1352),否则用户中途改时区会算错间隔。
  2. 时钟可能倒退。 算出的间隔为负也重新注入(:1213-1219),注释写着 "the machine clock went backwards"
  3. 格式必须带日期。 time_format 字段的描述专门警告:只有时分秒的格式会让解析回落到 1900 年,导致每轮都重新注入src/agentscope/agent/_config.py:182-191)。

任务注入的去重

判断「agent 是否已经知道有未完成任务」的方式很朴素但有效:倒着扫上下文,看有没有 TaskCreate / TaskList 之类的工具调用(比对 injection_config.task_tool_names),或者之前的 <tasks> 注入(:1136-1168)。有就不再提醒。压缩会把这些痕迹抹掉,于是压缩后自然会重新提醒一次——正好是需要提醒的时刻。

这段倒扫还顺手做了早停:时间和任务两个维度都settle 了就 break,不必扫完整个历史(:1136-1139)。


3.6 三件套怎么串起来

一次 Reasoning 动作的完整前置流程(src/agentscope/agent/_agent.py:1058-1069):

_next_action 返回 Reasoning

① 若有 hint,追加进上下文

② await self.compress_context() ← 可能触发压缩

③ _inject_runtime_state() ← 可能注入 HintBlock

④ _reasoning() → 调模型

顺序是有意的:先压缩再注入。压缩会清空时间痕迹,注入紧接着补上——所以压缩之后的第一次推理,模型一定知道现在几点。


3.7 代码地图

主题文件路径符号名
压缩入口与降级src/agentscope/agent/_agent.pycompress_context_compress_context_impl
切点计算与配对修正src/agentscope/agent/_agent.py_split_context_for_compression
工具结果切分src/agentscope/agent/_agent.py_split_tool_result_for_compression
运行时注入src/agentscope/agent/_agent.py_inject_runtime_state
读缓存清理src/agentscope/agent/_agent.py_clear_unreserved_read_cache
摘要结构与提示词src/agentscope/agent/_config.pySummarySchemaContextConfig
注入配置与模板校验src/agentscope/agent/_config.pyInjectionConfig_check_template
token 估算src/agentscope/model/_base.pycount_tokens
卸载协议src/agentscope/workspace/_offload_protocol.pyOffloader
卸载实现src/agentscope/workspace/_base.pyoffload_contextoffload_tool_result
提示块src/agentscope/message/_block.pyHintBlock
文件读缓存src/agentscope/state/_state.pyToolContextcache_fileclean_file_cache
相关测试tests/compress_context_test.pycompress_tool_result_test.pyagent_injection_test.py