跳到主要内容

数据截至 (上游 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-1660create_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_checkpointsnapshot_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_callsargs 字符串丢弃(state 里原件仍在)_truncate_args
② 结果卸载工具刚返回、结果超阈值超大 ToolMessage.content后端 /large_tool_results/{tool_call_id}_offload_tool_message_content
③ 历史摘要每次模型调用前,trigger 达标截断点之前的全部消息后端 /conversation_history/{thread_id}.mdwrap_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)只给 triggerkeep;max_length=2000truncation_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-131filesystem.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_fileoffset/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_callwrap_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-602CompositeBackend.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_pathNone,消息退化成不带路径的版本,同时 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_blockspath_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,四步:

  1. _find_tail_tool_message_batch(:53-61)—— 消息列表结尾是不是连续的 ToolMessage?不是就直接返回,一个字不动。

  2. 这批的 token 数够不够阈值?阈值从 keep 推导:tokens 型直取,fraction 型乘 max_input_tokens,拿不到就兜底 5_000(_derive_overflow_clip_threshold_tokens,:36-50)。

  3. 逐条按类型走两条路(_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)。

  4. 返回两个列表:改过的保留段(这次请求用),以及替换消息本身(写回 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 但没有对应 ToolMessageAIMessage

下次续跑,这段历史原样发给 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-3284middleware/_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-839patch_tool_calls.py:30-46 的组合行为)
  • 归档文件按调用隔离、名字随机。 session_id 是内部生成的 session_{uuid4},不报错也不提示;想跨调用定位同一份历史,只能读私有 state 里的 _summarization_session_id(:656-677)。

12. 横向对比

项目上下文怎么活下来和 Deep Agents 的差别
LangChain SummarizationMiddlewarebefore_model 里用 RemoveMessage(REMOVE_ALL_MESSAGES) 改写 state,旧消息直接丢Deep Agents 不改 state、旧消息落盘可回读、多一层 arg 截断和溢出兜底(差异清单见 :1656-1660)
LangGraph只提供 channel/reducer/checkpoint 机制本身Deep Agents 是这些机制的使用者:选 DeltaChannel、自带批式 reducer、自定 snapshot_frequency(见 LangGraph 的 channel 章)
Letta以"记忆分层"为一等公民,主动在核心/归档记忆间搬运Deep Agents 走的是"文件系统即记忆"路线,压缩产物落到后端路径(见 技能、记忆与自评)

13. 代码地图(导航索引)

主题文件(相对 libs/deepagents/deepagents/)关键符号
checkpoint 通道选型graph.pyDeepAgentState
批式消息 reducer_messages_reducer.py_messages_delta_reducer
中间件装配位置graph.pycreate_summarization_middlewarePatchToolCallsMiddleware
阈值自适应middleware/summarization.pycompute_summarization_defaultsSummarizationDefaults
摘要主流程middleware/summarization.py_DeepAgentsSummarizationMiddleware.wrap_model_call / awrap_model_call
摘要事件与投影middleware/summarization.pySummarizationEventSummarizationState_apply_event_to_messages_compute_state_cutoff
摘要消息识别middleware/summarization.py_is_summary_message_filter_summary_messages_build_new_messages_with_path
历史归档middleware/summarization.py_get_session_id_get_history_path_offload_to_backend
内联媒体卸载middleware/summarization.py_extract_data_url_decode_data_url_media_reference_block_rewrite_data_url_blocks_offload_inline_media_OFFLOAD_FAILED_PLACEHOLDER
摘要 prompt 拼装middleware/summarization.py_MEDIA_REFERENCE_SUMMARY_PROMPTDEEPAGENTS_DEFAULT_SUMMARY_PROMPT
工具参数截断middleware/summarization.pyTruncateArgsSettings_should_truncate_args_determine_truncate_cutoff_index_truncate_tool_call_truncate_args
token 计数兼容middleware/summarization.py_token_counter_accepts_tools_count_tokens
模型自助压缩middleware/summarization.pySummarizationToolMiddlewarecreate_summarization_tool_middleware_is_eligible_for_compaction_run_compact
大结果卸载(共享层)middleware/_message_eviction.pyTOO_LARGE_TOOL_MSG_create_content_preview_offload_tool_message_content
卸载触发与白名单middleware/filesystem.pyTOOLS_EXCLUDED_FROM_EVICTIONNUM_CHARS_PER_TOKEN_process_large_messagewrap_tool_call
超长用户消息卸载middleware/filesystem.py_check_eviction_needed_evict_and_truncate_messages_apply_eviction_and_truncate_build_truncated_human_messageTOO_LARGE_HUMAN_MSG
溢出兜底裁剪middleware/_overflow_clip.py_clip_overflow_tail_slice_read_file_tm_find_tail_tool_message_batch_derive_overflow_clip_threshold_tokens
悬空 tool_call 修补middleware/patch_tool_calls.pyPatchToolCallsMiddleware.before_agent
上游 channel 实现(LangGraph 克隆)langgraph/channels/delta.pyDeltaChannelreplay_writescheckpoint——见 LangGraph channel 章

接着读: 01-assembly-and-profiles.md(这些中间件是怎么被装上去的)· 02-backends.md(/conversation_history/large_tool_results 落到哪)· 03-filesystem-and-permissions.md(read_file 怎么把落盘内容读回来)· index.md