数据截至 (上游 commit 25aa2735dabb)
上下文工程:压缩、卸载与不让 checkpoint 爆炸
30 秒导读: 一个跑几小时、几百步的 agent,会同时把两样东西撑爆:发给模型的上下文窗口,和存到数据库的 checkpoint。Deep Agents 对这两件事分别下药——窗口侧用四道递进的防线把消息挤瘦,checkpoint 侧把消息通道从「每步存全量」换成「存增量 + 定期快照」。这一章讲这两条线怎么设计、怎么在源码里落地。
引用约定: 本章所有
path:line相对克隆里的 Python 包目录libs/deepagents/deepagents/。涉及上游 LangGraph / LangChain 内部实现的论断只给符号名,并链到本仓库另两套文档(LangGraph 章、LangChain 章)——它们的行号引用以各自文档集为准。
1. 问题:长跑 agent 的两个爆炸点
先说清这一章到底在解决什么。
一个 deep agent 的一次会话,本质是一条只增不减的消息列表:人类消息、AI 消息、工具调用、工具结果……跑得越久越长。长到一定程度,两个地方会先后崩。
| 爆炸点 | 谁受不了 | 崩的表现 | 增长量级 |
|---|---|---|---|
| 上下文窗口 | 模型 provider | 请求超出 max_input_tokens,直接被拒 | 与消息总量 O(N) |
| checkpoint 体积 | 数据库 / 存储 | 每步存盘越来越慢、越来越贵 | 朴素做法 O(N²) |
为什么 checkpoint 是 O(N²)? 这是最容易被忽略的一个。LangGraph 每个超步结束都要把状态写一份 checkpoint(见 LangGraph 的持久化章)。如果 messages 通道每步都把整个列表写进 checkpoint,那么跑 N 步就写了 1 + 2 + … + N ≈ N²/2 条消息的副本。
一句话直觉:
窗口是内存,checkpoint 是磁盘。 内存放不下要换出去;磁盘写太频要改成写日志(增量)而不是写快照(全量)。
这两条线在 Deep Agents 里是分开治的,而且刻意互相配合:
一条只增不减 的 messages 列表
│
┌─────────────┴─────────────┐
│ │
发给模型的那份 存进 checkpoint 的那份
(effective messages) (state["messages"])
│ │
①不改原列表, ②不存全量,
只按摘要事件投影 只存增量 + 每 50 步快照
│ │
§3 四道防线 §2 DeltaChannel
关键取舍:窗口侧的摘要不删 state 里的消息,只在 wrap_model_call 里算出一份"有效消息"给模型看(源码自述见 middleware/summarization.py:1656-1660 的 create_summarization_middleware docstring:LangChain 的做法是用 RemoveMessage(id=REMOVE_ALL_MESSAGES) 从 before_model 里改写状态,Deep Agents 不改)。好处是原始日志完整、可回放、可做 evals;代价是 state["messages"] 只增不减——于是 checkpoint 侧的止血就成了必需品,而不是锦上添花。
2. checkpoint 侧:把「每步存全量」换成「存增量」
2.1 一行代码改掉增长曲线
Deep Agents 的状态类只改了一件事:给 messages 换了个 channel。
class DeepAgentState(AgentState):
"""AgentState with `DeltaChannel` on messages to reduce checkpoint growth from O(N²) to O(N)."""
messages: Required[Annotated[list[AnyMessage], DeltaChannel(_messages_delta_reducer, snapshot_frequency=50)]]
—— graph.py:70-73,DeepAgentState。整章的 checkpoint 故事都从这一行展开。
2.2 DeltaChannel 做了什么
DeltaChannel 来自上游 LangGraph(不在本克隆内,符号与行为以 LangGraph 文档集的 channel 章为准)。它的核心动作反直觉:checkpoint() 什么都不返回,永远是 MISSING。
def checkpoint(self) -> Any:
"""Return stored representation: always `MISSING`."""
return MISSING
非快照步里,这个通道根本不出现在 channel_values 里;恢复时靠 saver 的 get_delta_channel_history 把祖先的 writes 走一遍 DeltaChannel.replay_writes 重放出来(该函数还专门处理"最后一个 Overwrite 当重置点"的语义)。
快照什么时候写?上游 docstring 说得很清楚:每次更新计数达到 snapshot_frequency,或距上次快照的超步数达到系统级上限 DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT(默认 5000),二者取或。LangGraph 的默认 snapshot_frequency 是 1000;Deep Agents 把它调到 50(graph.py:73),用更密的快照换更浅的重放深度。
读这张图的方式:从左到右是时间,方框是真正写进 checkpoint blob 的东西。
step: 1 2 3 ... 50 51 52 ... 100
朴素: [全] [全] [全] ... [全] [全] [全] ... [全] ← 每格大小 ∝ 当前列表长度
Delta: · · · ... [快照] · · ... [快照] ← "·" = 只有 writes,通道不进 blob
一个诚实的补丁: docstring 写的是「O(N²) → O(N)」。严格讲,增量那部分确实是 O(N),但周期快照仍会留下约 N/50 份全量副本,总量是 O(N²/50) 量级——常数被 snapshot_frequency 直接压掉 50 倍,而不是彻底消掉多项式。$(inferred,基于 DeltaChannel.checkpoint 永远返回 MISSING、快照由 create_checkpoint 按 snapshot_frequency 写入的机制)
2.3 reducer 的四条规则
DeltaChannel 的 reducer 签名和普通 reducer 不同:一次收一批 writes,reducer(state, [w1, w2, ...]) -> new_state。Deep Agents 自己写了这个批式 reducer。
def _messages_delta_reducer(
state: list[AnyMessage] | None, writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
—— _messages_reducer.py:31,_messages_delta_reducer。它的行为可以拆成四条:
| 规则 | 怎么做 | 源码 |
|---|---|---|
| 按 id 去重更新 | 维护 id -> 下标 索引;同 id 再次写入就原地替换,而不是追加 | _messages_reducer.py:71-90 |
RemoveMessage 打墓碑 | 把 result[index[mid]] 置为 None、删索引,最后一趟过滤掉 None | _messages_reducer.py:81-84、:90 |
REMOVE_ALL_MESSAGES 整表重置 | 找最后一个该哨兵,清空 state_msgs,并丢掉哨兵之前的所有写入 | _messages_reducer.py:61-69 |
| 原始输入自动转型 | dict / str / tuple 走 convert_to_messages,让 HTTP 驱动的图不用额外转换步 | _messages_reducer.py:52-59 |
第一条是后面几节反复用到的 杠杆:凡是用原 id 写回一条改过的消息,reducer 都会原地覆盖而不是追加(§5.4、§7 都靠它)。第三条也不是摆设:PatchToolCallsMiddleware 就是靠 RemoveMessage(id=REMOVE_ALL_MESSAGES) 做整表改写的(见 §8)。
还有一条快路径值得注意:reducer 自己的输出已经是 typed BaseMessage,所以稳态下跳过 convert_to_messages,只有真正的原始输入(初始 dict、反序列化 blob)才走慢路径(_messages_reducer.py:52-58)。同一行还处理了 state is None——DeltaChannel.replay_writes 对「最早的 checkpoint 没有播种 messages: []」的线程会传 None 进来。
2.4 为什么不在 reducer 里分配 id
这是本文件最值得抄走的一条设计说明,写在模块 docstring 里:
ID assignment is intentionally absent here.(
_messages_reducer.py:10-15)
理由分两层:
- 冗余:LangGraph 的
ensure_message_ids在写进 checkpoint 之前就给所有BaseMessage盖了稳定 UUID,reducer 看到时 id 已经有了。 - 危险:reducer 在 replay 时也会跑。如果它在这里随机生成 id,重放出来的 id 会和 checkpoint 里存的那个不一致——去重索引直接失效,同一条消息会变成两条。
一句话:任何会在 replay 路径上被重复执行的函数,都必须是纯函数。 随机数、时间戳、自增计数器在这里都是 bug。
3. 上下文窗口侧:四道防线
窗口侧不是一招,是四道按代价递增的防线。先看全景——从上到下是"越往下丢的信息越多":
消息列表越来越长
│
① 工具参数截断 ────────┤ 只砍旧 write_file/edit_file 的入参
(最便宜,先试) │ state 不动 → 完全可逆
│
② 工具结果卸载 ────────┤ 超大 ToolMessage 写进后端文件
(工具返回时) │ 留 head+tail 预览 + 路径 → 可 read_file 找回
│
③ 历史摘要 ────────┤ 旧消息交给模型压成一段 summary
(阈值触发) │ 全文追加到 /conversation_history/{thread}.md
│
④ 溢出后尾部裁剪 ───────┘ provider 已经拒了,最后一招:切尾巴
(ContextOverflowError) read_file 结果头切,其余整体卸载
各防线的触发点、动的东西、信息去向:
| 防线 | 何时触发 | 动了什么 | 信息去哪 | 主要符号 |
|---|---|---|---|---|
| ① 参数截断 | 每次模型调用前,trigger 达标 | 旧 AIMessage.tool_calls 的 args 字符串 | 丢弃(state 里原件仍在) | _truncate_args |
| ② 结果卸载 | 工具刚返回、结果超阈值 | 超大 ToolMessage.content | 后端 /large_tool_results/{tool_call_id} | _offload_tool_message_content |
| ③ 历史摘要 | 每次模型调用前,trigger 达标 | 截断点之前的全部消息 | 后端 /conversation_history/{thread_id}.md | wrap_model_call |
| ④ 溢出裁剪 | 捕获 ContextOverflowError 之后 | 保留段尾部那批 ToolMessage | 原路径 或 /large_tool_results/… | _clip_overflow_tail |
注意 ① 和 ③ 是同一次调用里的两步:wrap_model_call 先截参数、重新数 token,再判断要不要摘要——截断常常就把 token 拉回阈值以下,直接省掉一次摘要调用(middleware/summarization.py:1377-1386;设计意图见 :1656-1660)。
接下来逐道拆。
4. 防线①:工具参数截断(最便宜的一刀)
4.1 它解决的小问题
对话里最占地方的往往不是模型说了什么,而是它十步之前写文件时塞进 args 的那 3000 行代码。文件已经写完了,那段入参对后续推理几乎没价值,但每次请求都要重发一遍。
4.2 配置与触发
配置项是 TruncateArgsSettings(middleware/summarization.py:161-189),四个键:trigger(阈值,None 即关闭)、keep(留多少最近消息不动)、max_length(单个参数值的字符上限)、truncation_text(截断后缀)。
判定逻辑很直白——messages 比条数、tokens 比总量、fraction 比模型 profile 的 max_input_tokens 乘以比例(_should_truncate_args,:840-868)。拿不到 profile 时 fraction 直接返回 False(:859-862),不猜。
切在哪由 _determine_truncate_cutoff_index 决定(:870-918):messages 型就是 len - keep;tokens/fraction 型则从后往前累加 token,加到超预算的那条就停。
4.3 真正动手的那一段
for tool_call in msg.tool_calls:
if tool_call["name"] in {"write_file", "edit_file"}:
truncated_call = self._truncate_tool_call(tool_call)
—— middleware/summarization.py:1022-1024,_truncate_args 主循环。单个参数的处理是 _truncate_tool_call(:919-946):字符串且超 max_length 的,只留前 20 个字符再接截断提示(:935)。
两个坑,都值得记:
- 白名单是硬编码的。
TruncateArgsSettings的 docstring 说截断后缀接在「参数前 20 个字符」之后(:186-188),但代码只对{"write_file", "edit_file"}动手(:1023)——execute的大参数不会被截。 - 默认值来自两个地方。
compute_summarization_defaults(:255-301)只给trigger和keep;max_length=2000和truncation_text="...(argument truncated)"是__init__的兜底(:610-615)。
4.4 token 计数的兼容处理
顺带一个工程细节:TokenCounter 协议只要求接受消息,但现代计数器多半还接受 tools=(把工具 schema 也算进去)。怎么判断?不靠 try/except——那分不清「签名不收 tools」和「计数器体内真的抛了 TypeError」。
_token_counter_accepts_tools(:220-253)直接用 inspect.signature 看参数表,返回三态:True / False / None(签名不可内省,如某些 C 级 callable)。结果在 __init__ 里算一次存起来(:593),_count_tokens(:947-986)按三态分支,只有 None 那条路才会吞 TypeError。
同一处还有个性能考量:数 token 要做工具 schema 转换,很贵,所以 wrap_model_call 只数一次,截断检查和摘要检查共用(:1373-1375);只有截断真的改了消息才重数(:1384-1385)。
5. 防线②:大工具结果卸载
术语: 上游对同一件事有两个叫法——模块叫
_message_eviction.py、常量叫TOOLS_EXCLUDED_FROM_EVICTION(eviction),函数却叫_offload_tool_message_content(offload)。本书统一叫卸载,只在写符号名时保留 eviction 原词。
5.1 思路
一次 grep 或一次 execute 可能吐出几十万字符。与其让它常驻上下文,不如当场写进文件系统,消息里只留一张"提货单"。
原理演示(示意,非源码):
# 工具刚返回,先量一下体积
if len(text) > CHARS_PER_TOKEN * limit:
path = f"/large_tool_results/{tool_call_id}" # 提货单地址
backend.write(path, text) # 全文落盘
text = TOO_LARGE_TOOL_MSG.format( # 消息里只留头尾预览 + 路径
tool_call_id=tool_call_id, file_path=path,
content_sample=head_and_tail(text),
)
5.2 真实实现
拦截点在 FilesystemMiddleware.wrap_tool_call(middleware/filesystem.py:3458-3483):工具跑完先看名字,再看体积。阈值是 tool_token_limit_before_evict(默认 20000,filesystem.py:1617),换算成字符用 NUM_CHARS_PER_TOKEN = 4(filesystem.py:905);判定和替换在 _process_large_message(filesystem.py:3135-3180)。
落盘与改写由共享 helper 完成:_offload_tool_message_content(middleware/_message_eviction.py:119-142)。三个细节:
tool_call_id要洗过再当文件名(sanitize_tool_call_id,_message_eviction.py:132)。- 写失败返回
None,调用方保留原消息——宁可撑 上下文,不能丢内容(:129-131、filesystem.py:2740-2741)。 - 非文本块原样保留。
_build_evicted_content(:82-102)只替换 text 块,图片/音频块继续挂在替换消息上,多模态上下文不会因为一次卸载消失。
预览格式是 head 5 行 + ... [N lines truncated] ... + tail 5 行,每行还砍到 1000 字符封顶(_create_content_preview,:37-63)。存根文案 TOO_LARGE_TOOL_MSG(:25-34)明确教模型用 read_file 带 offset/limit 分段读回来——这就是它和 文件系统中间件 的接缝。
5.3 白名单:哪些工具不卸载
TOOLS_EXCLUDED_FROM_EVICTION = (
"ls", "glob", "grep", "read_file", "edit_file", "write_file", "delete",
)
—— middleware/filesystem.py:1477-1486。上面那段注释(:1445-1476)给了三类理由,值得原样记住:
| 类别 | 工具 | 为什么排除 |
|---|---|---|
| 自带截断 | ls / glob / grep | 结果太多说明查询该收窄,多余匹配更像噪声,不值得保全 |
| 截断反而有害 | read_file | 失败模式是单行超长(如 jsonl);截断后模型多半会再 read_file 一次,毫无帮助 |
| 从不超限 | edit_file / write_file / delete | 只返回一句确认,检查它们纯属浪费 |
5.4 同族的第二条通道:超长用户消息
卸载这件事有两个入口,同属 FilesystemMiddleware,但触发时机相反:上面那条在工具返回后看 ToolMessage,这一条在模型请求前看 HumanMessage(用户直接粘进来一整份日志、一整个 CSV)。03 章 §10.2 列的三个入口,机制在这里收口。
| 工具结果通道(§5.2) | 用户消息通道 | |
|---|---|---|
| 触发点 | wrap_tool_call | wrap_model_call → _evict_and_truncate_messages(filesystem.py:3285,由 :3088 调用) |
| 条件 | 结果文本 > 4 × tool_token_limit_before_evict(20000) | 最后一条消息是未打标的 HumanMessage 且文本 > 4 × human_message_token_limit_before_evict(50000)(:3211-3235、:1618) |
| 落点 | {artifacts_root}/large_tool_results/{tool_call_id} | {artifacts_root}/conversation_history/{uuid4}.md(:3236-3284,前缀在 :1692) |
| state 里剩什么 | 替换后的存根 | 全文原样保留,只在 additional_kwargs["lc_evicted_to"] 上打一个路径标记(:3236-3284) |
第二条通道最值得学的是**「state 存全文、请求现算截断版」**:打过标的消息每次请求都由 _build_truncated_human_message(:1523-1546)现场生成存根(文案 TOO_LARGE_HUMAN_MSG,:1488-1497;非文本块同样保留,_build_evicted_human_content,:1498-1522),纯字符串计算、不碰后端。
打标那一步刻意用原 id 写回而不是发 REMOVE_ALL_MESSAGES 哨兵,理由写在 docstring 里(:3236-3284):原 id 会被 §2.3 的 reducer 原地去重覆盖,而哨兵会连带清掉模型节点在同一个超步里写的 AIMessage。这是"防线"和"checkpoint 通道"两条线互相咬合的又一处。
6. 防线③:历史摘要中间件
这是四道防线里最核心、也最有设计密度的一道。
6.1 装配位置
create_deep_agent 默认就把它装上,主 agent 和内置的 general-purpose 子 agent 各一份:
create_summarization_middleware(model, backend),
PatchToolCallsMiddleware(),
—— graph.py:843-844(主 agent)与 graph.py:758-759(general-purpose 子 agent);子 agent 那份见 子 agent 章。
工厂 create_summarization_middleware(middleware/summarization.py:1626-1702)只做一件加值的事:按模型 profile 挑阈值。
if has_profile:
return {"trigger": ("fraction", 0.85), "keep": ("fraction", 0.10),
"truncate_args_settings": {"trigger": ("fraction", 0.85), "keep": ("fraction", 0.10)}}
—— compute_summarization_defaults,:268-281。模型没暴露 max_input_tokens 时退回保守的固定值:trigger=("tokens", 170000)、keep=("messages", 6),截参数则用 ("messages", 20)(:285-301)。
6.2 主流程:wrap_model_call 三步走
effective = 应用历史摘要事件(§6.3)
│
total_tokens = _count_tokens(effective, system_message, tools) ← 只数一次
│
Step1 ── _truncate_args ──▶ 改了? 重数 token
│
Step2 ── _should_summarize? ── 否 ─▶ 正常调模型
│ └─ 抛 ContextOverflowError? ─▶ 落到 Step3
是
│
Step3 ── _determine_cutoff_index ──▶ <=0? 放弃压缩,照常调
│
├─ _partition_messages → (要摘要的, 要保留的)
├─ (仅溢出路径) _clip_overflow_tail 裁尾巴 §7
├─ _offload_inline_media 把内联 data: 媒体换成路径引用 §6.5
├─ _offload_to_backend 全文追加到 /conversation_history/{thread}.md
├─ _create_summary 让模型压出一段 summary
└─ 用 [summary, *保留段] 调模型,并把事件写回 state
—— middleware/summarization.py:1335-1475,_DeepAgentsSummarizationMiddleware.wrap_model_call(异步孪生在 :1476-1625)。
注意 Step2 的乐观策略:阈值说"不用摘要"时,它先真的试一次,被 provider 以 ContextOverflowError 拒了才回落到摘要路径(:1390-1395)。这比"每次都保守压缩"省很多摘要调用。
阈值与切点本身是复用 LangChain 的。 _should_summarize / _determine_cutoff_index / _partition_messages / _create_summary 全部委托给 self._lc_helper(:647-669)。切点那条最值得看一眼上游:_find_safe_cutoff_point 会避免把 AI/Tool 消息对切开——如果切点正好落在 ToolMessage 上,就向前回溯找发起这批调用的 AIMessage,把切点挪到它前面(实现属上游 LangChain 的 SummarizationMiddleware,见 LangChain 中间件章)。切开这对消息,下一次请求会被 provider 直接拒——和 §8 要解决的是同一类病。
异步路径还多一个优化:落盘和生成摘要互不依赖,用 asyncio.gather 并发跑(:1569-1571)。
6.3 事件回放:摘要为什么不改 state
这是本章最该抄走的设计。摘要不返回新的消息列表,而是往私有状态字段 里塞一个事件:
class SummarizationEvent(TypedDict):
cutoff_index: int
summary_message: HumanMessage
file_path: str | None
—— middleware/summarization.py:135-146;字段挂在 SummarizationState._summarization_event 上(:191-206),用 PrivateStateAttr 标记为私有。
有了事件,"模型该看到什么"就是一个纯函数投影:
result: list[AnyMessage] = [summary_msg]
result.extend(messages[cutoff_idx:])
—— _apply_event_to_messages(:774-812),静态方法,自动摘要与 compact_conversation 工具共用。它对畸形事件和越界 cutoff_index 都有防御(:794-807),坏事件只会退化成"不压缩",不会炸。
链式摘要的坐标换算是这个设计里唯一绕的地方。事件里存的是绝对下标,但 _determine_cutoff_index 算出来的是有效列表里的下标,而有效列表第 0 位是那条不属于真实历史的 summary 消息:
return prior_cutoff + effective_cutoff - 1
—— _compute_state_cutoff(:813-839)。那个 -1 就是在扣掉 summary 消息占的位子。
摘要消息自己也要能被认出来,靠 additional_kwargs 里的 lc_source="summarization"(_is_summary_message,:692-708)。_filter_summary_messages(:709-723)在落盘前把旧摘要滤掉——原始消息早就在归档文件里了,再存一遍是纯冗余。
6.4 归档文件:摘要里留一条回家的路
摘要之后旧消息还能不能找回来?能。_offload_to_backend(:1179-1255)把它们按 XML 格式追加进一个每次调用一份的 markdown 文件:
- 路径
{artifacts_root}/conversation_history/{session_id}.md(_get_history_path,:679-691;前缀在:598-602由CompositeBackend.artifacts_root决定,详见 后端章)。 session_id的语义换了:旧版从 LangGraph config 里取thread_id(取不到就造session_{uuid8});现在改成_get_session_id(:656-677)——从私有 state 字段_summarization_session_id里复用,没有就现生成一个session_{uuid4 hex}并随事件写回 state。它是按图调用隔离的:每次调用(包括每个子 agent)各得一份历史文件,不跨线程共享;uuid 用满熵,免得共享同一 backend 的两个会话把历史写串(:675-676 的注释)。- 每次摘要追加一段
## Summarized at {ISO 时间}+get_buffer_string(..., format='xml')(:1211)。 - 读旧内容用
download_files而不是read——因为read返回的是带行号的、给 LLM 看的内容,而edit需要原文(:1214-1218)。
然后把路径写进摘要消息本体,让模型知道去哪找:
The full conversation history has been saved to {file_path} should you need to refer back to it for details.
—— _build_new_messages_with_path(:724-756)。落盘失败时 file_path 是 None,消息退化成不带路径的版本,同时 logger.error + warnings.warn 双管齐下告诉你"旧消息不可恢复"(:1429-1431)。
6.5 内联媒体卸载:别把 base64 喂进摘要
一条带图的消息里可能是几百 KB 的 data:image/png;base64,...。两个问题:摘要 prompt 不该收到原始字节;而且 XML 历史渲染器会把任何内联 data: URL 整个丢掉——不处理就等于静默丢图。
处理链条是五个小函数,各司其职:
| 函数 | 职责 | 位置 |
|---|---|---|
_extract_data_url | 认出三种内联块形状(显式 base64 字段 / url 是 data: / OpenAI 风格 image_url),纯检测不抛异常 | :315-367 |
_decode_data_url | 解码成 (bytes, ext, mime);;base64, 走 b64,否则按百分号编码处理(如内联 SVG) | :368-397 |
_media_reference_block | 按 MIME 大类生成 image/audio/video 块,其它类型退化成 <file url="…" /> 文本块 | :398-418 |
_rewrite_data_url_blocks | 按 path_map 把内联块换成引用块,换不掉的写占位符 | :419-471 |
_offload_inline_media | 两趟:先按内容哈希去重上传,再整体重写 | :1044-1125 |
存储路径是 {artifacts_root}/conversation_history/media/{sha256[:16]}.{ext}(:1060),按内容哈希去重(:457)——同一张图在十条消息里出现,只上传一次。
失败不静默:解码失败或上传失败的块会变成
<image error="failed_to_offload" />
—— _OFFLOAD_FAILED_PLACEHOLDER(:295)。失败块数一路上抛,归档成功但有媒体丢了时,警告会把丢失和归档路径绑在一起说,免得给出一个假的"都存好了"指针(:1432-1441)。
最后一环是告诉摘要模型这些标签是什么。_MEDIA_REFERENCE_SUMMARY_PROMPT(:100-107)明确交代:标签代表原消息有媒体、路径可用 read_file 打开、不要脑补图里有什么。它被拼进 DEEPAGENTS_DEFAULT_SUMMARY_PROMPT——插在 LangChain 默认 prompt 的 <messages> 标记之前(:113-115),替换式拼装本身就是契约级的锚点操作(:107-112)。
7. 防线④:溢出之后的兜底裁剪
7.1 什么时候轮到它
已经被 provider 拒了一次(ContextOverflowError),摘要路径也走了,但保留段本身可能还是太大——典型情况是最后一轮并发发了 8 个工具调用,回来 8 个巨型 ToolMessage,它们全在"保留"那一侧。
于是溢出路径额外调一次 _clip_overflow_tail(middleware/summarization.py:1407-1416)。
7.2 怎么裁
实现在 middleware/_overflow_clip.py:131-173,四步:
-
_find_tail_tool_message_batch(:53-61)—— 消息列表结尾是不是连续的 ToolMessage?不是就直接返回,一个字不动。 -
这批的 token 数够不够阈值?阈值从
keep推导:tokens型直取,fraction型乘max_input_tokens,拿不到就兜底5_000(_derive_overflow_clip_threshold_tokens,:36-50)。 -
逐条按类型走两条路(
_clip_one_tail_message,:105-115):这条 ToolMessage 来自 怎么处理 为什么 read_file内容头切到 4000 字符,追加一条指回原 file_path的提示全文早就在后端那个路径上了,不用再写一份 其它任何工具 整体卸载到 /large_tool_results/{tool_call_id},换成TOO_LARGE_TOOL_MSG存根内容只存在于消息里,必须先落盘 read_file那条的原路径是从对应的 tool_call 参数里反查出来的(_read_file_original_path,:96-102;索引由_build_tool_call_index预建,:64-73)。截断提示还刻意模仿了read_file自身截断时的文案形状,让模型看到的格式一致(_slice_read_file_tm,:76-93)。 -
返回两个列表:改过的保留段(这次请求用),以及替换消息本身(写回 state)。替换消息带原 id,所以 reducer 会按 id 原地覆盖而不是追加(
:145-151;在 Deep Agents 里执行覆盖的正是 §2.3 的_messages_delta_reducer)。写回动作在wrap_model_call结尾:
update: dict[str, Any] = {"_summarization_event": new_event}
if new_state_tail:
update["messages"] = list(new_state_tail)
—— middleware/summarization.py:1463-1468。这是整条摘要链路里唯一真正改写 state["messages"] 的地方,而且改的是内容不是结构。
单条落盘失败(_offload_tool_message_content 返回 None)就保留原件,并且不进替换列表——两个列表各自保持一致(_overflow_clip.py:169-172)。
8. 断点续跑的卫生:补齐悬空 tool_call
8.1 病
用户在 agent 发出工具调用、结果还没回来时按了 Ctrl-C,或者进程崩了。checkpoint 里于是留下一个有 tool_calls 但没有对应 ToolMessage 的 AIMessage。
下次续跑,这段历史原样发给 provider —— 直接 400。所有主流 provider 都要求每个 tool_call 有配对的结果。
8.2 药
PatchToolCallsMiddleware.before_agent(middleware/patch_tool_calls.py:11-46)在 agent 开跑前扫一遍,给每个悬空调用补一条 ToolMessage,文案还分两种:
| 情况 | 补的内容 |
|---|---|
invalid_tool_call(参数畸形/被截断) | ... could not be executed - arguments were malformed or truncated. |
| 普通 tool_call 无结果 | ... was cancelled - another message came in before it could be completed. |
—— patch_tool_calls.py:40-43。
两个实现细节:
- 先探测再动手。 没有任何悬空调用就直接
return None,不产生任何状态写入(:22-28)。 - 整表改写。 因为要在中间插消息,只能返回
[RemoveMessage(id=REMOVE_ALL_MESSAGES), *patched_messages](:46)——这正是 §2.3 里_messages_delta_reducer必须支持REMOVE_ALL_MESSAGES重置的原因。
它在装配里紧跟摘要中间件之后(graph.py:843-844),两者一起构成"续跑第一步"的卫生检查。
9. 让模型自己按压缩键
前面四道防线都是框架替模型决定。还有一条正交的路:把压缩做成工具,让模型自己判断什么时候该按。
9.1 工具与提示
SummarizationToolMiddleware(middleware/summarization.py:1793-2154)注册一个 compact_conversation 工具(_create_compact_tool,:1857-1889)。什么时候该用的一段"提示"不再内置:构造参数 system_prompt 默认 None——不传就完全不追加任何 nudge,工具照样注册、照样可调(:1834-1843);要引导模型,调用方自己传一段 prompt fragment,由 wrap_model_call 追加进 system message(:2110-2132)。
注意它自己从不自动压缩(:1800-1802)——只有被真的调用才动。要自动的那份,得另外注册 SummarizationMiddleware,两者通过同一个 _summarization_event 键互通(:1808-1809,便捷工厂里的说明在 :1732)。create_summarization_tool_middleware(:1703-1792)是一次配好两层的便捷工厂。
一个小遗留:CompactConversationSchema(:131-133)定义了空入参 schema,但在 StructuredTool.from_function 里被注释掉了(:1886),当前靠 infer_schema 从函数签名推。
9.2 别让它压得太早
模型可能刚说两句就想"清个场"。所以有一道资格闸门:必须达到自动摘要阈值的约 50% 才允许压缩。
@staticmethod
def _compact_threshold(value: float) -> int:
return max(1, int(value * 0.5))
—— :1991-1994。三种阈值形态各自折半(_is_compaction_clause_met,:2003-2021),dict 型要求全部满足,list 型任一满足即可(_is_eligible_for_compaction,:2022-2041)。判定优先用模型上报的 usage metadata,比自己估更准。
不够格就返回一句友好的 ToolMessage 而不是报错:「Nothing to compact yet — conversation is within the token budget.」(_nothing_to_compact,:1943-1963)。
9.3 工具必须返回,不能抛
_run_compact(:2042-2075)把摘要生成和落盘整个包在 try 里,任何异常都转成一条说明性的 ToolMessage:
Compaction failed: … The conversation has not been compacted — no messages were summarized or removed.
—— _compact_error(:1964-1990)。这条注释说明了原因:# tool must return a ToolMessage, not raise(:2070)。工具节点抛异常会让整个图停下,而这里失败的只是一次可选优化,退回原状即可。文案还明确告诉模型"什么都没删",避免它误以为历史已经没了。
10. 巧妙之处(可以抄走的)
- 摘要做成"事件 + 投影",而不是"改写历史"。 状态里只多一个
_summarization_event,模型看到什么由纯函数_apply_event_to_messages算出来(middleware/summarization.py:774-812)。原始日志完整保留,replay、evals、多个中间件共享压缩结果全都白拿。 - reducer 里绝不做非确定性的事。 不分配 id,理由写在
_messages_reducer.py:10-15:reducer 在 replay 时也跑,随机 id 会和 checkpoint 里的对不上。 - 改一条消息用「原 id 写回」,不用哨兵。 靠 reducer 的按 id 去重原地覆盖,既不打乱结构,也不会清掉同一超步里模型节点写的
AIMessage(middleware/filesystem.py:3236-3284、middleware/_overflow_clip.py:145-151)。 - 先乐观试一次,被拒再压。
ContextOverflowError当成信号而非故障(:1390-1395),省掉大量不必要的摘要调用。 - 昂贵的探测只做一次。 计数器签名内省缓存在
__init__(:593);token 只数一次在两个检查间共用(:1373-1375)。 - 卸载的白名单带理由。
TOOLS_EXCLUDED_FROM_EVICTION上面那 30 行注释把"为什么read_file截断反而有害"讲清楚了(middleware/filesystem.py:1445-1486)——这是把设计决策写进代码而不是写进某人脑子里的范例。 - 失败一律留痕、绝不静默。 媒体丢了写
<image error="failed_to_offload" />(:295),归档失败logger.error+warnings.warn(:1429-1431),部分媒体失败时警告和归档路径绑定说(:1432-1441)。 - read_file 的结果不重复落盘。 溢出裁剪时它只切头 + 指回原路径,因为全文本来就在后端(
middleware/_overflow_clip.py:76-93)。
11. 边界与局限(诚实清单)
state["messages"]只增不减。 摘要不删消息,所以 checkpoint 的绝对体积仍随会话线性增长——DeltaChannel压的是写放大,不是总量。两个机制是配套的:少了DeltaChannel,这套"不改历史"的设计会很贵。DeltaChannel官方标注 beta。 上游明说 API 和磁盘表示可能变,get_delta_channel_history/_DeltaSnapshot/counters_since_delta_snapshot这些周边契约尚未稳定(beta 警告写在DeltaChannel的类 docstring,见 LangGraph channel 章)。- 参数截断只认两个工具名。
{"write_file", "edit_file"}硬编码在:1023,不可配;execute之类的大参数不受保护(尽管 docstring 提到了它)。 - 用户消息卸载只看最后一条。
_check_eviction_needed只检查messages[-1](middleware/filesystem.py:3211-3235),历史中段的超长HumanMessage不会被处理。 fraction阈值依赖模型 profile。 拿不到max_input_tokens时,_should_truncate_args直接返回False(:859-862),compute_summarization_defaults也会整体退回固定值(:285-301)——冷门模型上这套自适应会静默降级。- 溢出裁剪只处理"尾部连续 ToolMessage"。 结尾不是 ToolMessage,或大内容散落在中段,
_clip_overflow_tail一动不动(middleware/_overflow_clip.py:153-155)。 - 摘要事件的下标是绝对下标。 如果有中间件在 cutoff 之前插入消息,
cutoff_index会失准。PatchToolCallsMiddleware补的通常是尾部的悬空调用,所以实践中一般不冲突,但这是设计上的耦合点。(inferred,基于:813-839与patch_tool_calls.py:30-46的组合行为) - 归档文件按调用隔离、名字随机。
session_id是内部生成的session_{uuid4},不报错也不提示;想跨调用定位同一份历史,只能读私有 state 里的_summarization_session_id(:656-677)。