第 8 章 长任务不迷路
本章代码
code/ch08/main.py· 实验code/ch08/context_lab.py· 读完约 20 分钟
8.0 上一章留下的问题
第 7 章的 Buddy 会干活、会守边界、也知道什么时候该问人。但它有两个隐患,都跟"任务变长"有关。
第一个隐患:它没有进度感。 你让它"整理工作区里 20 个文件",它可能做到第 7 个就忘了还剩哪些,也可能重复处理第 3 个。原因不是模型笨,是它的"记忆"就是一串追加的消息——做到第 30 步的时候,第 2 步写在任务描述里的那三条要求,已经被埋在几千 token 之前。
第二个隐患:上下文会撑爆。 每一次工具结果都被追加进上下文。读 20 个文件,就是 20 段原文。窗口是有限的,撞上去只有两种结局:调用报错,或者悄悄丢掉最早的消息——而最早那条往往正是你的原始要求。
这一章解决这两件事:给模型一本外部笔记本,以及给上下文装一个闸门。
8.1 任务清单为什么能提升完成率
先做一个思想实验。同一个任务,两种提示方式:
方式 A:帮我整理这 20 个文件,为每个写一行摘要。
方式 B:帮我整理这 20 个文件。先用 TaskCreate 建 20 条任务,
每做一条就标记 in_progress,做完标记 completed。差别不在措辞,在于状态被外置了。
方式 A 里,"还剩哪些没做"是一个需要模型自己维护的隐式状态。它在第 15 步时必须从上下文里翻出最初那句话,再减去自己已经做过的事——这是推理负担,而且会随步数增加越来越不可靠。
方式 B 里,这个状态变成了一条 TaskList 调用。模型不需要记住任何东西,它只需要在动手前查一次:查清单是一次工具调用,不是一次回忆。
这就是"外部笔记本"的全部含义。它顺便还带来两个好处:你可以从外面看见进度;中断之后可以照单接着做。
AgentScope 把这本笔记本放在 agent.state.tasks_context 里,与上下文消息数组平行存放。这个位置很关键——后面会看到,上下文被压缩时,任务清单不受影响。
8.2 四个任务工具
agentscope.tool 里有四个开箱可用的工具,把它们加进 Toolkit 即可:
from agentscope.tool import TaskCreate, TaskGet, TaskList, TaskUpdate
toolkit = Toolkit(tools=[
*workspace_tools,
TaskCreate(), TaskList(), TaskUpdate(), TaskGet(),
])构造参数为空。它们怎么拿到 Agent 的状态?答案是框架在分发调用时自动注入了一个参数 _agent_state:
async def call(self, _agent_state: AgentState, subject: str, description: str, ...)这一点在写自定义工具时值得记住:参数名以下划线开头,模型看不到它,框架会替你填。 你自己写工具时也可以用它拿到状态。
任务对象 Task 的字段如下(来自 agentscope.state):
| 字段 | 类型 | 说明 |
|---|---|---|
id | str | 创建时自增得到,从 "1" 开始 |
subject | str | 一句话标题 |
description | str | 详细说明 |
state | "pending" / "in_progress" / "completed" | 注意字段名是 state,不是 status |
owner | str | None | 归属,多人协作时用 |
blocks / blocked_by | list[str] | 依赖关系,存的是任务 id |
metadata / created_at | — | 附加信息与创建时间 |
TaskUpdate 的 status 参数有四个取值。前三个 pending / in_progress / completed 是真正的任务状态,会被写进 Task.state;最后一个 deleted 不是状态,而是一个删除命令:TaskUpdate.call() 里对 status == "deleted" 单独处理,直接把任务从 tasks_context.tasks 里 pop() 掉,然后返回,不会执行后面的 task.state = status。任务对象都没了,自然不会有 state="deleted",所以 Task.state 的类型只有前三个值。可以把它理解成同一个 status 字段兼了两个职责:改状态,或删任务。
不接模型也能验证这套工具。下面这段代码直接调用 call(),手工把状态传进去:
state = AgentState()
await TaskCreate().call(_agent_state=state, subject="清点沙箱目录", description="列出 sandbox 下所有文件")
await TaskCreate().call(_agent_state=state, subject="生成目录摘要", description="写一份 notes.md")真实输出:
[create] Task (id=1) created successfully: 清点沙箱目录
[create] Task (id=2) created successfully: 生成目录摘要
state.tasks: [('1', '清点沙箱目录', 'pending'), ('2', '生成目录摘要', 'pending')]
[update] Update task (id=1) status.
state.tasks: [('1', '清点沙箱目录', 'in_progress'), ('2', '生成目录摘要', 'pending')]
[list] 1 [in_progress] 清点沙箱目录
2 [pending] 生成目录摘要TaskList 的返回值就是模型看到的东西——两行文本,带状态。它足够短,短到每次重新读一遍都不心疼。
8.3 上下文的三个抽屉
现在处理第二个隐患。要理解压缩,先要看清上下文里到底有哪些东西。

图 8-1 上下文的三个抽屉——压缩只替换滑动层,外置层是摘要之外的第二重保险
常驻层:system_prompt 和工具 schema。前者每次调用都完整发送,后者也不在消息数组里,而是作为请求的独立字段。这部分不参与压缩,因为它是"你是谁",压缩掉就没法干活了。
滑动层:state.context,一个消息数组。用户的每句话、模型的每次回复、每次工具调用与结果,都追加在这里。只有这一层会被压缩。
外置层:三样东西,都独立于消息数组——
state.summary:压缩产生的摘要state.tasks_context:任务清单,压缩不影响- 磁盘上的文件:超长的工具结果被卸载成文件,模型需要时用
Read自己回去取
这个分层解释了一个常见的疑问:"摘要会不会把重要信息弄丢?"答案是会丢细节,但不丢线索。摘要保留的是"什么文件、第几步、遇到什么错",原文如果需要,还在磁盘上。
8.4 ContextConfig:四个数字决定生死
Agent 的构造参数里有一个 context_config:
agent = Agent(
...,
context_config=ContextConfig(
trigger_ratio=0.8,
reserve_ratio=0.1,
tool_result_limit=50_000,
),
)这段配置给 Agent 装上上下文闸门:上下文用到窗口 80% 时触发压缩,压缩后保留 10% 空间,单条工具结果超过 5 万 token 就把超出部分交给 offloader 落盘。
字段的默认值来自 ContextConfig 的定义:
表 8-1 ContextConfig 的关键字段
| 字段 | 默认值 | 含义 |
|---|---|---|
trigger_ratio | 0.8 | 用量达到窗口的多少比例时触发压缩。取值必须 >0 且 <=0.9 |
reserve_ratio | 0.1 | 压缩后为目标保留多少空间 |
context_buffer_ratio | 0.2 | 触发前的预警带宽度,进入这条带就会收到"即将压缩"的提示 |
tool_result_limit | 50000 | 单条工具结果的 token 上限,超出部分交给 offloader 卸载 |
compression_fallback_to_truncation | True | 摘要生成失败时,退化为截断最早的消息 |
compression_tool_enabled | False | 是否额外提供一个"主动压缩"工具给模型 |
max_image_num | 5 | 上下文里最多保留几张图片 |
触发线的算法是乘法,写在源码里:
# 触发压缩的 token 阈值 = 上下文窗口大小 × 触发比例
threshold = context_config.trigger_ratio * model.context_size所以 context_size 是个关键量。它来自模型对象:OpenAIChatModel 自己的默认值是 128000(32768 是更底层的 ModelBase 默认值),而且不同 2.x 小版本之间也可能调整过,别指望记准。用第三方兼容接口时,这个值可能就是错的——你的模型其实只有 32k,它以为有 128k,结果压缩迟迟不触发,最后 API 直接回 400。显式传一次最稳妥:
OpenAIChatModel(credential=..., model=..., context_size=32_768)trigger_ratio 为什么限制在 0.9 以下?因为压缩自己也要消耗上下文——它要把整段历史连同"请写一份摘要"的指令一起发给模型。留不出这 10%,压缩请求本身就会超限。
8.5 压缩发生时到底做了什么
顺序是这样的:
- 每次模型推理前检查:ReAct 循环每次进入
Reasoning前都会调用compress_context();另外cur_iter == 0时还会额外注入一条"上下文快满了"的提示 - 用
model.count_tokens()估算当前输入大小。基类实现很简单:字节数除以 4 - 超过
trigger_ratio × context_size→ 进入压缩 - 先试摘要:把历史消息加上
compression_prompt发给模型,要求按summary_schema输出五个字段 - 失败则截断:
compression_fallback_to_truncation=True时,删掉最早的消息,把 summary 写成一句Some earlier messages were truncated for limited context. - 用
summary_template把五个字段渲染成一段<system-info>文本,替换掉整段历史
摘要的五个字段值得单独看一遍,它其实是"一份好的交接文档"该有的结构:
| 字段 | 要求 |
|---|---|
task_overview | 用户的核心请求与成功标准,含澄清与约束 |
current_state | 已完成什么,产出在哪个路径 |
important_discoveries | 技术约束、做过的决策与理由、踩过的错 |
next_steps | 具体待办、阻塞项、优先级 |
context_to_preserve | 用户偏好、承诺、不明显但重要的领域细节 |
框架给的 compression_prompt 里还有一条硬要求,容易被忽略:摘要里的一切引用必须自包含。相对时间要换成绝对日期,"那个文件"要换成完整路径,"那三个失败的用例"要换成用例名。原因是这份摘要将来可能被再压缩一次,两次之后,任何需要回看原文才能理解的表述都会失效。
8.5.1 实测:压缩到底省了多少 token
上面讲的是机制,但只看代码很难有体感:压缩什么时候发生、能省多少?code/ch08/context_lab.py 用假模型跑同一份工作负载,不花 API 费用。工作负载是六轮检索,每轮工具结果约 1600 token;两组配置只有窗口不同:
- 第一组窗口给到 100 万,大到六轮跑完也碰不到触发线,效果等于关闭压缩。
- 第二组窗口 4000、触发线 0.8,也就是 3200 token。4000 是实验参数,不是推荐值——它足够小,能让压缩在六轮内发生;真实模型要按自己的实际窗口设置。
真实输出:
=== 关闭压缩(大窗口) ===
每次模型调用时的输入 token 数:
144 -> 1652 -> 3160 -> 4668 -> 6176 -> 7684 -> 9192
峰值 9192 末值 9192
上下文消息数 2 摘要长度 0
=== 开启压缩(4k 窗口,触发线 3200) ===
每次模型调用时的输入 token 数:
144 -> 1652 -> 3160 -> 4936 -> 258 -> 1766 -> 3542 -> 258 -> 1766
峰值 4936 末值 1766
上下文消息数 1 摘要长度 293
摘要: <system-info>Here is a summary of your previous work # Task Overview 用户要求分 6 批检索资料,最后汇总成一份结果。…
图 8-2 同一份工作负载下的输入 token 走势(数据来自 code/ch08/context_lab.py 的实跑输出)
先别被那串数字绕晕:它混了两种调用。正常推理调用的序列是:
144 -> 1652 -> 3160 -> 258 -> 1766 -> 258 -> 1766多出来的 4936 和 3542 是压缩摘要请求的输入,不是正常对话上下文。峰值 4936 也因此不是"上下文峰值",只是所有调用里的最大值。
三条读图结论:
第一,不压缩时,上下文随工具结果线性增长。 每轮加 1500 token 左右,六轮后每次调用已经要带 9192 token。按这个斜率,二十轮就逼近两万。
第二,压缩后,正常上下文是锯齿。 3160 之后压到 258,再涨到 1766;第二次压缩后又回到 258。摘要只有 293 字,所以压缩后的起点很低,但之后仍会继续累积。
第三,压缩不是免费的。 4936、3542 这两次尖峰就是压缩摘要本身的成本:框架要额外发一次模型请求,把旧上下文总结成摘要。窗口设成 4000 时,第一次压缩前上下文已经到 4668,超过窗口本身,框架会警告预留空间不足;这也说明窗口不能卡得太死,trigger_ratio 和 context_size 都要给压缩请求留余量。
8.6 注入:把易变信息从 system prompt 里拿出来
有一类信息会变,但又不该写进 system prompt:现在几点、还有几个任务没做完、上下文快到顶了、某个工具连续失败三次了。
把它们写进 system prompt 是错的——system prompt 是每轮原样重发的,改它相当于改人设。全塞进用户消息也是错的——那会干扰用户的原话。
AgentScope 的做法是在每轮开始前拼一条 HintBlock,追加到上下文末尾。缓存的键是注入内容的类别:
| 注入项 | 触发条件 | 内容 |
|---|---|---|
current-time / timezone | 距上次注入超过 time_interval(默认 0.5 小时) | 当前时间与所在时区 |
tasks | 存在未完成任务,且模型尚未感知到 | "你有 N 个进行中、M 个待办任务" |
context-length | 用量进入预警带 | "当前 N token,到 M 时会压缩" |
tool-error | 同一工具、同样参数连续失败达到 tool_retries_limit(默认 3) | 见下方提示模板 |
时间注入有个节流细节:不是每轮都注入,而是距上次注入超过半小时才再注入一次。因为在一轮快速连续的调用里,时间几乎没变,重复注入纯属浪费 token。
工具重复失败的提示模板是现成的:
The last {count} calls to '{tool_name}' with the same arguments all failed.
Stop retrying the same call as-is, check the error message and try a different approach.这句话解决了一个很常见的死循环:模型不信邪,把同一个调用原样重试五遍。注入之后它才知道要换个方法。
配置入口是 injection_config:
injection_config=InjectionConfig(
timezone="Asia/Shanghai", # 默认 UTC,国内项目一定要改
time_format="%Y-%m-%d %H:%M",
time_interval=0.5,
emit_hint_event=True, # 让 HintBlock 也出现在事件流里,方便前端展示
)emit_hint_event=True 是我们把它设成 True 的原因:第 12 章的前端要画一个"调试抽屉",把这些注入显示出来。默认就是 True,本章的 render() 里已经在处理 HINT_BLOCK 事件,运行时能直接打出:
[注入] <system-reminder>Treat the following as the ground truth at this point8.7 完整代码
code/ch08/main.py 把三件事拼在一起:任务工具、上下文闸门、注入配置。
toolkit = Toolkit(tools=[
*workspace_tools,
TaskCreate(), TaskList(), TaskUpdate(), TaskGet(),
])
agent = Agent(
name="Buddy",
system_prompt=(
"你叫 Buddy,做事之前先列计划。"
"任务超过三步时,用 TaskCreate 建清单,"
"开始做时用 TaskUpdate 标记 in_progress,做完标记 completed。"
"\n\n" + instructions
),
model=build_model(),
toolkit=toolkit,
offloader=workspace,
context_config=ContextConfig(
trigger_ratio=0.8,
reserve_ratio=0.1,
tool_result_limit=50_000,
),
injection_config=InjectionConfig(
timezone="Asia/Shanghai",
time_format="%Y-%m-%d %H:%M",
time_interval=0.5,
),
)system prompt 里那三句话不是装饰。任务工具不会自动被使用,你得告诉模型什么时候用它。写成"任务超过三步时"比"你可以用 TaskCreate"有效得多,前者给了判断标准,后者只给了权限。
结尾的 describe_state() 把平时看不见的东西打出来:
def describe_state(agent: Agent) -> None:
tasks = agent.state.tasks_context.tasks
print(f"任务清单({len(tasks)} 条)")
for task in tasks:
print(f" {task.id:<3} [{task.state:<11}] {task.subject}")
print(f"上下文消息数:{len(agent.state.context)} "
f"摘要长度:{len(agent.state.summary or '')} 字")跑一次长任务,你会看到"上下文消息数"从十几条突然掉到个位数、摘要长度从 0 跳到几百——那就是压缩发生的时刻。
8.8 三个常见错误
错误 1:把 trigger_ratio 设为 1.0
ContextConfig(trigger_ratio=1.0)直接抛校验错误,因为字段定义是 gt=0, le=0.9。即便放宽,也不该这么做:压缩请求本身要占用上下文,压到 100% 再去压缩,压缩请求自己就超限了,结果是必然失败、必然退化为截断。
错误 2:第三方接口沿用默认 context_size
接一个只有 32k 窗口的模型,却让 context_size 停在 128000。表现为:跑到第十几轮时 API 突然返回 400,错误信息里写着 context length exceeded,而你的 ContextConfig 看起来一点问题没有。压缩压根没触发过——因为它算出来的触发线是 102400。
错误 3:以为任务清单也会被压缩
# 想靠压缩清理任务,结果什么都没发生
agent.state.context.clear()任务在 state.tasks_context,不在 state.context。清消息数组不会清任务,压缩也不会。要清任务得显式操作 tasks_context.tasks,或者用 TaskUpdate(status="deleted")。
反过来,这正是设计意图:摘要会丢掉过程,清单不会丢。 上下文被压缩到只剩一段摘要之后,模型第一件事应该是 TaskList,而不是重新规划一遍。
8.9 小结
- 任务清单的价值在于把状态外置:查清单是工具调用,不是回忆
- 四个任务工具靠注入的
_agent_state访问状态;Task.state与工具的status参数名不一致 - 上下文分三层:常驻层不压缩,滑动层整段替换,外置层(摘要 / 任务 / 磁盘)压缩后仍可查
- 触发线 =
trigger_ratio × model.context_size,检查只在每轮开始时做,所以要留余量 - 压缩优先走摘要,失败退化为截断;摘要五段结构就是一份交接文档
- 易变信息用
HintBlock注入,不要改 system prompt timezone默认 UTC,国内项目必须改
到这里,Buddy 能干活、能守边界、能问人、能做长任务。但它的一切都在内存里——关掉终端,全没了。
下一章要解决的问题:Agent 到底是有状态还是无状态?把 agent.state 存成 JSON 再读回来,哪些东西会坏?
