走过的路只剩下最后一格
目标
把智能体的状态设计成合并规则,而不是键的列表。先亲手复现没有 Reducer 时会发生什么,再实现接续拼接、去重、取最大值、截取窗口这四种 Reducer,并把输入和输出 schema 分开,收窄对外输出的键。
为什么重要
LangGraph 的状态,是每个键对应一个通道的结构。节点返回只包含想修改的键的字典,由图逐个通道应用。此时没有写 Reducer 的键会被覆盖——三个节点依次写入的 trace 只剩最后一个,原因就在这里。因为不报错,看上去像是“没有留下日志”,人会怀疑日志代码,白白浪费时间。
反方向的问题也有。加上 Reducer 后,那个键会无休止地增长,而 checkpointer 会整个保存状态。节点运行三十次,检查点也有三十个,每一个都是那一时刻的完整状态。所以对会增长的键,要把上限写在 Reducer 里——分散在各个节点里的话,只要漏掉一处,就会悄悄泄漏。
最后,状态中会混进不应让外部看到的东西。用输出 schema 以白名单方式确定要输出的键,即使新增了键也会自动被挡住。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的值直接检验 Reducer,并把运行图得到的状态与评分器另外计算的值对照。节点名称和数字每次运行都会变。
步骤
- 在 /root/work/agstate/state.py 中创建
CATALOG、State、三个节点(intake、lookup、finish)和build_graph()。节点只返回要修改的键,没有动过的键必须保持原样。 - 增加
overwrite_demo(names),用它复现没有 Reducer 的键被覆盖的情形。为每个名称创建一个节点并依次连接,每个节点向seen中写入自己的名称。 - 给
State的trace加上Annotated[list, operator.add],让三个节点的足迹按顺序累积。 - 创建 Reducer
merge_sources(old, new)并加到sources键上。相同的来源只保留一次,顺序保持首次出现的顺序。 - 给
used_calls加上operator.add,给peak_ms加上自己实现的keep_max(old, new)。 - 创建
RECENT_KEEP = 3和 Reducerkeep_recent(old, new),让recent键只保留最后三个。finish一次写入两个。 - 创建
InputState和OutputState,让build_public()返回用StateGraph(State, input=InputState, output=OutputState)编译的图。结果中只能出现answer。 - 在 /root/work/agstate/state_report.json 和 /root/work/agstate/state_report.md 中记录状态设计。
参考
- 执行契约:评分器会把
/root/work/agstate/state.py当作 Python 模块导入,直接使用CATALOG、State、build_graph、overwrite_demo、merge_sources、keep_max、keep_recent、RECENT_KEEP、build_public。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 build_graph()返回的是已经 compile() 的图。如果返回StateGraph本身,就没有.invoke()。- 节点顺序是
intake→lookup→finish。写入trace的名称也要与这三个一致。 intake从问题中找出CATALOG的键放入target,lookup把个数放入found(没有则为 -1),finish把"<이름> 재고는 <개수>개입니다"(韩文,意为“<名称> 库存为 <数量> 个”)放入answer。- Reducer 是接收
(옛값, 새값)(占位符依次为旧值与新值)两个参数的普通函数。不能直接传入内置函数——Annotated[int, max]会在编译时以ValueError: no signature found for builtin max崩溃。 - 对
sources,intake写入["질문"],lookup写入["질문", "재고목록"],finish写入["재고목록"](韩文,意为“问题”“库存清单”)。Reducer 正确的话,结果只有两项。 used_calls每个节点写 1,peak_ms是intake写 4、lookup写 17、finish写 9。recent中,intake写入["intake"],lookup写入["lookup"],finish写入["finish:작성", "finish:검토"](韩文,意为“撰写”“审阅”)。- 这个 Pod 没有互联网。
pip install无法使用。langgraph 0.2.60 已经装好了(python3 -c "import langgraph")。 - 官方文档:Graph API overview · Use the graph API · Persistence
- 常见错误:节点返回整个状态(只返回要修改的键)、去掉
Annotated只定义 Reducer 函数、把内置max当作 Reducer、把窗口截取放进节点而不是 Reducer。
节点只返回要修改的键
在 /root/work/agstate/state.py 中创建 CATALOG、State、三个节点(intake、lookup、finish)和 build_graph()。build_graph() 返回已经 compile() 的图,节点没有动过的键必须保持原样。
用 TypedDict 定义状态,把节点加入 StateGraph(State),再从 START 连到 END。节点不返回整个状态,而是返回只包含想修改的键的字典——这样其他节点放进去的值才不会被抹掉。加上 total=False,键就不必全部填满。
没有 Reducer 就只剩最后一个
增加 overwrite_demo(names)。为 names 中的每个名称创建一个节点并依次连接,每个节点向没有 Reducer 的键 seen 中写入自己的名称。返回值是 {"writes": [...], "state_after": [...]}。
用循环创建节点时,要小心 lambda 只抓住最后一个名称这个经典陷阱——要用默认参数或外层函数把名称绑定住。writes 中按顺序放入你创建的节点名称,state_after 中放入运行图之后实际剩下的 seen 值。这一步是有意复现错误的设计。
累积足迹的 Reducer
把 State 的 trace 改成 Annotated[list, operator.add],让三个节点的足迹按 ["intake", "lookup", "finish"] 的顺序累积。
需要 from typing import Annotated 和 import operator。放在 Annotated 第二个位置的函数,就是该键的 Reducer。节点一侧的代码保持不变——变的只是状态定义中的一行。这就是这个设计的要点。
同一个来源不写两次
创建 Reducer merge_sources(old, new) 并加到 sources 键上。相同的值只保留一次,顺序保持首次出现的顺序。三个节点分别写入 ["질문"]、["질문", "재고목록"]、["재고목록"](韩文,意为“问题”“库存清单”)。
Reducer 是接收 (옛값, 새값)(占位符依次为旧值与新值)两个参数并返回新值的普通函数。用 set 去重会打乱顺序,所以要在保持顺序的同时过滤。旧值可能还不存在,所以要像 old or [] 这样写,使其能容忍空值。
要累加的,和只保留最大的
给 used_calls 加上 operator.add,给 peak_ms 加上自己实现的 keep_max(old, new)。每个节点给 used_calls 写 1,给 peak_ms 写入:intake 为 4、lookup 为 17、finish 为 9。
数字也可以加 Reducer。要点是不要把计数规则分散在各个节点里,而是集中到状态定义的一行中。不能把内置 max 直接作为 Reducer——LangGraph 会查看 Reducer 的签名,而内置函数没有签名,会在编译时崩溃。包一层就行。
给会增长的键设上限
创建 RECENT_KEEP = 3 和 Reducer keep_recent(old, new),让 recent 键只保留最后三个。intake 写入 ["intake"],lookup 写入 ["lookup"],finish 写入 ["finish:작성", "finish:검토"](韩文,意为“撰写”“审阅”)。
进来四个,只能留下三个,所以合并之后再截取的顺序很重要。不要把上限放进节点,而要放进 Reducer——节点以后还会增加,只要漏掉一处,就会悄悄泄漏。checkpointer 会整个保存状态,这就是要设这个上限的原因。
只输出该输出的
创建 InputState(只有问题)和 OutputState(只有答案),让 build_public() 返回用 StateGraph(State, input=InputState, output=OutputState) 编译的图。只传入 {"question": ...} 运行的结果里,只能有 answer 这一个键。
节点仍然看到完整状态,变窄的只有入口和出口。把连接节点的代码提成函数,build_graph() 和 build_public() 就可以共用同一套接线。用删除输出内容的代码来拦截,每新增一个键就会落后一步——白名单会自动拦住。
留下为什么这样决定
在 /root/work/agstate/state_report.json 中写入 reducers、trace、sources、used_calls、peak_ms、recent_len、public_keys,在 /root/work/agstate/state_report.md 中用 ## 무엇을 상태에 두었나(韩文,意为“把什么放进了状态”)、## 어떤 리듀서를 왜 붙였나(韩文,意为“给哪些键加了哪种 Reducer,为什么”)、## 자라지 않게 막은 곳(韩文,意为“防止增长的地方”)、## 바깥에 내보내지 않는 것(韩文,意为“不向外输出的内容”)四节来写。
数字不要手写,要用实际运行你的图得到的值来填。reducers 是以键名为键、以所加 Reducer 的名称为值的对象(例如 operator.add)。public_keys 是 build_public() 的结果中实际出现的键的列表。评分器会另外计算同样的内容来对照。