TT Lab
开始
学习 学习路径 课程

AI 智能体 — 是图不是模型

走过的路只剩下最后一格

在 TT Lab 中继续学习

目标

把智能体的状态设计成合并规则,而不是键的列表。先亲手复现没有 Reducer 时会发生什么,再实现接续拼接、去重、取最大值、截取窗口这四种 Reducer,并把输入和输出 schema 分开,收窄对外输出的键。

为什么重要

LangGraph 的状态,是每个键对应一个通道的结构。节点返回只包含想修改的键的字典,由图逐个通道应用。此时没有写 Reducer 的键会被覆盖——三个节点依次写入的 trace 只剩最后一个,原因就在这里。因为不报错,看上去像是“没有留下日志”,人会怀疑日志代码,白白浪费时间。 反方向的问题也有。加上 Reducer 后,那个键会无休止地增长,而 checkpointer 会整个保存状态。节点运行三十次,检查点也有三十个,每一个都是那一时刻的完整状态。所以对会增长的键,要把上限写在 Reducer 里——分散在各个节点里的话,只要漏掉一处,就会悄悄泄漏。 最后,状态中会混进不应让外部看到的东西。用输出 schema 以白名单方式确定要输出的键,即使新增了键也会自动被挡住。 评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的值直接检验 Reducer,并把运行图得到的状态与评分器另外计算的值对照。节点名称和数字每次运行都会变。

步骤

  1. 在 /root/work/agstate/state.py 中创建 CATALOG、State、三个节点(intake、lookup、finish)和 build_graph()。节点只返回要修改的键,没有动过的键必须保持原样。
  2. 增加 overwrite_demo(names),用它复现没有 Reducer 的键被覆盖的情形。为每个名称创建一个节点并依次连接,每个节点向 seen 中写入自己的名称。
  3. 给 State 的 trace 加上 Annotated[list, operator.add],让三个节点的足迹按顺序累积。
  4. 创建 Reducer merge_sources(old, new) 并加到 sources 键上。相同的来源只保留一次,顺序保持首次出现的顺序。
  5. 给 used_calls 加上 operator.add,给 peak_ms 加上自己实现的 keep_max(old, new)。
  6. 创建 RECENT_KEEP = 3 和 Reducer keep_recent(old, new),让 recent 键只保留最后三个。finish 一次写入两个。
  7. 创建 InputState 和 OutputState,让 build_public() 返回用 StateGraph(State, input=InputState, output=OutputState) 编译的图。结果中只能出现 answer。
  8. 在 /root/work/agstate/state_report.json 和 /root/work/agstate/state_report.md 中记录状态设计。

参考

节点只返回要修改的键

在 /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() 的结果中实际出现的键的列表。评分器会另外计算同样的内容来对照。