数据截至 (上游 commit f72f75d8c3eb)
03 · 上下文管理与容错
本章讲长程 agent 的两个生死问题:上下文会越滚越大直到爆窗口;工具会抖(超时、空返回、格式错)。看这套代码怎么在不训练、不改模型的前提下,靠工程手段把长跑撑住。
3.1 问题:上下文只增不减
每一轮都往 messages 追加「模型输出 + 工具观察」,而工具观察(尤其网页摘要)动辄几千 token。跑几十轮,上下文必然逼近模型的 128K 窗口。撑爆了会直接报错或胡言乱语。
3.2 对策:超阈值就「强制收尾」
核心在 react_agent.py:186-209。每轮结束都数一次 token,超过 110K(110 * 1024)就触发一次「最后通牒」:
# react_agent.py:186-193(节选)
max_tokens = 110 * 1024
token_count = self.count_tokens(messages)
if token_count > max_tokens:
messages[-1]['content'] = (
"You have now reached the maximum context length ... "
"You should stop making tool calls and ... provide ... "
"<think>...</think>\n<answer>your answer</answer>"
)
content = self.call_server(messages, planning_port) # 逼它现在就答
巧妙处: 它不是粗暴截断历史,而是改写最后一条消息成一句强指令,再逼模型立刻用现有信息给 <answer>。这样答案仍基于完整证据,只是不再允许继续搜。收尾后按有没有 <answer> 标记 termination 为「因 token 上限而生成答案」或「格式错」(react_agent.py:196-201)。
为什么阈值是 110K 而非 128K?留了约 18K 余量给「最后一次回答」本身要占的输入+输出——这是个务实的安全边际。
3.3 三道时间/轮数闸门
除了上下文,还有三处硬闸,防止单题无限跑:
| 闸门 | 阈值 | 行为 | 代码 |
|---|---|---|---|
| 轮数 | 100 次 LLM 调用 | 用完就把末条改成「超调用上限」并收尾 | react_agent.py:29、183-184 |
| 单题墙钟 | 150 分钟 | 直接返回「2h30min 未找到答案」 | react_agent.py:140-150 |
| visit 内部 | 多 URL 累计 900 秒 | 剩余 URL 返回兜底文本 | tool_visit.py:83-94 |
另外 run_multi_react.py:192-207 在任务层还兜了个 TimeoutError(>1800s), 把整题标成 [Failed] 写盘,保证一道题卡死不会拖垮整批。
3.4 处处重试,但退避策略各异
这套代码对「外部依赖会抖」有充分预期,几乎每个网络调用都包了重试。但风格不统一,值得对照看:
| 位置 | 重试次数 | 退避 | 代码 |
|---|---|---|---|
| 调主模型 | 10 | 指数退避 + 抖动,封顶 30s | react_agent.py:71-108 |
| Serper 搜索 | 5 | 无退避,直接重连 | tool_search.py:63-72 |
| Jina 抓页(内层) | 3 | 固定 sleep 0.5s | tool_visit.py:146-166 |
| Jina 抓页(外层) | 8 | 无退避 | tool_visit.py:169-177 |
| 摘要 LLM | 可配(默认 1) | 无 | tool_visit.py:107-129 |
| 沙箱执行 | 8 | 无退避,随机换端点 | tool_python.py:76-109 |
只有调主模型用了正经的指数退避(react_agent.py:101-106):base * 2**attempt + rand(0,1),封顶 30 秒。其余多是「硬重试」——反映出这些外部服务(Serper/Jina/沙箱)通常是瞬时抖动,重连即可。
3.5 降级:失败也要给出「能用」的东西
最能体现「长跑韧性」的是 visit 的多级降级(tool_visit.py:202-253),它的哲学是宁可给次优结果,也不让整轮崩:
怎么读这张图:从上往下依次降级,任一级成功即停:
摘要成功? ──是──► 返回 evidence + summary
│否
内容截到 0.7× 再摘要(重复 3 次) ──成功──► 返回
│仍失败
砍到 25000 字最后一搏 ──成功──► 返回
│仍失败
JSON 解析重试 3 次 ──成功──► 返回
│仍失败
返回兜底句「网页无法处理,无信息可用」(而非抛异常)
关键:兜底返回的是一段格式合法的文本(tool_visit.py:234-237、251-253),主 循环把它当普通观察回填,模型看到「这页读不了」后可以改去搜别的——失败被转化成了信息,而不是让循环挂掉。同样的哲学也见于工具派发:找不到工具返回错误字符串而非抛异常(见 02 §3.5)。
3.6 关键细节 / 坑
-
Python 工具的错误信息里写「5」但实际试 8 次。
tool_python.py:76循环range(8),但打印和判断里写的是Attempt .../5、if attempt == 4(tool_python.py:80,98,106)——即真正的提前返回发生在第 5 次,后 3 次是死代码。功能上仍是「最多 5 次有效重试」,但注释/日志有误导。 -
数 token 每轮重载 tokenizer。 见 01 §3.7,
count_tokens每次from_pretrained,长轨迹下是明显开销。 -
摘要 LLM 默认只重试 1 次。
VISIT_SERVER_MAX_RETRIES默认 1(tool_visit.py:192),靠外层「截断再摘要」的降级兜住,而不是靠重试。
代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 上下文超限强制收尾 | inference/react_agent.py | MultiTurnReactAgent._run(max_tokens = 110*1024 处) |
| 主模型指数退避 | inference/react_agent.py | MultiTurnReactAgent.call_server |
| 单题墙钟闸 | inference/react_agent.py | _run(150 * 60 处) |
| 任务级超时兜底 | inference/run_multi_react.py | as_completed 循环中的 TimeoutError 分支 |
| visit 多级降级 | inference/tool_visit.py | Visit.readpage_jina |
| 网页文本截断 | inference/tool_visit.py | truncate_to_tokens |
| 沙箱随机端点重试 | inference/tool_python.py | PythonInterpreter.call |