LangChain系列之一个完整 Agent 实战:3 条支线、3 层记忆、3 道护栏

2026-09-21 16:260 条评论386 次阅读

一个完整 Agent 实战:3 条支线、3 层记忆、3 道护栏

这套零件我拆了 10 篇。上下文、记忆、工具、评测、护栏,每一件单独拿出来都能讲清楚。

可前阵子我想把产线里那个搜索 Agent 的骨架抽出来,做成一个别人下载下来就能跑的东西时,还是在同一个地方卡了很久。

卡住我的不是零件,是接缝。

记忆该在哪一轮召回、路由按什么切才不打架、checkpoint 该存在哪一层、评测集怎么覆盖多 Agent 场景,这四个问题前面没有一篇讲得到,因为每一篇都在讲一件零件。

装配本身是一件独立的手艺。

所以这一篇不教新东西。我只做一件事:把这些零件装成一个能跑、能验、能改的完整项目,然后把装配过程中塌过的三个地方指给你看。


一、把零件装成一条线

1.1 零件都在,缺的是焊点

先把家底盘一遍。前面那些文章给过你这些东西:

你手里已有的零件

模型装配

结构化输出

检索增强

StateGraph

checkpoint 与中断

上下文治理

长期记忆

工具分层与 Skill

多 Agent 路由

评测与轨迹

上线护栏

要装成的这一条线:购物助手

浏览器界面
配好模型就能对话

三条支线
导购 订单 客服

三层记忆
摘要 画像 事件

三道护栏
幂等 金额 轮数

这张图里每一格你都见过。真正的问题是中间那条线:这 13 个零件装进一个工程,每个落在哪一层、由谁调用、出错了谁兜。

我把这件事做成一张映射表。这张表是整篇文章的骨架。

零件 来自 在购物助手里的落点
模型装配与工厂 《LangChain 1.0 之后》 make_brain() 双后端:规则实现 / 真实端点
结构化输出 《模型 I/O 与结构化输出》 意图识别节点,Pydantic 定义 Route
检索增强 《RAG 实战》 商品检索 + 政策检索,纯 Python BM25
StateGraph 主图 《LangGraph 实战》 一张主图承载全链路
checkpointer 《LangGraph 进阶》 SQLite 落盘,会话可续
interrupt 中断 《LangGraph 进阶》 下单前的金额确认
上下文治理 《Agent 的记忆(上)》 长会话裁剪 + 早期摘要
长期记忆 《Agent 的记忆(下)》 用户画像与事件记忆,跨会话召回
工具分层 《MCP 与 Skills 接入》 货架与清单分离,按支线发工具
Skill 承载流程 《MCP 与 Skills 接入》 退换货判定写成 markdown
多 Agent 路由 《多 Agent:拆之前,先回答这 3 个问题》 导购 / 订单 / 客服三条支线
评测集与轨迹 《评测与监控》 20 条用例 + 每轮轨迹落盘
上线护栏 《Agent 上线实战》 幂等键、轮数上限、金额阈值

装完这一遍我才发现,映射表里最容易被低估的是最后两行。评测和护栏在前面的文章里都是独立话题,装进来之后它们不是两个模块,是两层网:轨迹每轮都写,护栏在每一步都站着。

1.2 这个工程长什么样

它是一套浏览器应用,不是一个脚本集合。

pip install -r requirements.txt
python run.py

跑完这两行,浏览器会打开 http://127.0.0.1:8765,不配模型也能用。界面左边是对话,可以选 5 个不同的用户身份,方便你看同一句话对不同人的反应。

右侧是过程面板,路由到了哪条线、调了什么工具、召回了哪几条记忆、上下文裁掉了几条,都在上面。右上角是模型设置,填地址和密钥就换成真模型。

五条硬标准,是我做这个工程时给自己定的:

标准 具体到什么程度
零配置可跑 不联网、不要密钥,python run.py 起服务就能对话。没配模型时走内置规则实现,链路完整,只是回答死板
可切真模型 界面上填地址与密钥即可。本地服务与云端都在预设里,配好点一下「测试连接」
过程可见 每轮的路由依据、工具调用、记忆召回、上下文治理、真实 token 用量,界面上都能看见
可改 数据是 JSONL,工具与提示词分层。换成自己的数据不用动主图
能上生产 九处外部依赖各自收在一个文件或一个函数里,逐个换掉就行,主图一行都不用动

最后一条是这一篇真正想给你的东西。这五条合起来,给出的是一副能换零件的骨架。模型、数据、检索、记忆、存储、工具、业务口径、界面,每一处都是一个单独的接口。

第十一章会把九处接口逐个列出来,写明每一处改哪个文件、换成什么。

第三条我要多说一句。一个看不见内部过程的 Agent,你没法调试它。

这个工程里第九章要讲的三处塌陷,全部是靠过程面板抓出来的。光看对话,你完全看不出来。

1.3 先说清楚它不做什么

做这种项目最怕贪心。我砍掉的东西比留下的多:

  • 不做真实支付。 下单落一条记录进本地文件,没有支付网关,没有回调。
  • 不接向量库。 检索是纯 Python 的 BM25,为了让人解压就能跑,不为了效果。
  • 不做多进程。 单进程跑。真要多副本部署,幂等和记忆的那两个 SQLite 得换成服务端存储。
  • 不追求回答质量。 它的价值在链路完整,不在答得多聪明。换个 9B 模型,你马上能感觉到它的天花板。

这四条边界我会在结尾再提一次。能说清自己做不到什么,是一个工程可用的前提。


二、先造一份数据

2.1 为什么先做数据

写 Agent 工程有个顺序问题:先写代码还是先造数据。

我的经验是先造数据。因为数据会反过来决定代码的形状。

你手上只有「商品」和「价格」两张表,检索永远写不出「差评集中点」这种查询。你手上没有促销规则里那个 stackable 字段,算价就永远不会遇到叠加的坑。

这个工程的数据是 7 个 JSONL,全部构造,商品与品牌均为虚构:

文件 条数 说明
products.jsonl 40 商品,耳机 20 / 键盘 12 / 显示器 8。specs 是异构字典
users.jsonl 5 用户档案:偏好、回避、身体条件、常去场景
orders.jsonl 12 历史订单
logistics.jsonl 12 物流轨迹
policies.jsonl 27 平台规则,带 scope 支持先过滤再排序
reviews.jsonl 60 评价,带标签,支持按标签聚合
promotions.jsonl 10 促销规则,带 stackable 与生效时间

2.2 三个设计决定

第一,specs 用异构字典,不用固定列。 耳机的参数是降噪深度和续航,键盘的参数是轴体和配列,显示器的参数是分辨率和刷新率。把它们硬塞进一张宽表,80% 的格子是空的。

异构字典的代价是「参数对比」这个工具必须写得通用。这恰恰是好事:真实商品数据就长这样,你早晚要写通用版本。

第二,订单直接引用业务化编码。 我第一版让订单里的商品用「第几个商品」的位置索引。结果增删一个商品,所有订单集体错位。改成 SKU-E1001 这种带类目前缀的编码之后,这个问题再没出现过。

第三,评价按类目分开生成。 混用一个评价池会造出「耳机评价里说屏幕有坏点」这种东西。分开之后还要注意一点:差评不是低分商品的专利。 销量大的商品评价多,必然混着几条差评。我按「低分商品 + 每隔几个」两条规则铺,最后 13 个商品带差评。

2.3 校验器比人眼可靠

写了一个 31 项的校验脚本,跑起来一句话:

python eval/validate_data.py

它抓出来的问题,一个都不是我肉眼发现的:

问题 现场 根因
订单引用的商品全部错位 12 笔订单里的 SKU 全对不上 用位置索引当外键,增删商品就集体偏移
成交价与单价 × 数量不自洽 手写的成交价对不上乘法结果 常量手写,没跟着单价改
评价与类目串味 耳机评价里出现屏幕坏点 各类目共用一个评价池
差评覆盖不足 只有 9 个商品带差评 只按评分挑,漏了「销量大必然有差评」这条规律

这 4 项全部由校验器抓出。数据层的错误,后果是后面所有环节一起错,而且错得很像对的。 值得为它写一个专门的脚本。


三、一张图承载整条链路

3.1 主图只有 4 个环节

购物助手 Agent 的 LangGraph 结构

上面这张图不是画出来的示意图。每一个节点名、每一条边、每一组工具数量,都是从工程的编译结果里打出来的:

app = build_graph(sess)
print(app.get_graph(xray=1).draw_mermaid())

图的最下方那块是附注,讲三条支线内部长什么样。把那一层摘掉,整条链路只剩下四个环节:

用户消息

① 装载
取历史 + 召回长期记忆 + 上下文治理

② 路由
结构化输出得到 Route

③ 导购线
检索 比价 偏好召回

③ 订单线
算价 幂等 金额确认

③ 客服线
政策检索 Skill 流程

③ 闲聊兜底
不调工具 带记忆

④ 归档
写记忆 + 更新会话 + 状态瘦身

四个环节。这张骨架和大多数人的第一直觉不一样:三条支线是并列的子图,不是一个循环里的三个分支。 每条支线有自己的循环、自己的工具清单、自己的轮数预算。

主图只干两件所有支线都要干的事,进门治理上下文,出门写记忆。

3.2 为什么这两件事必须放主图

这是个很容易做错的决定。直觉上「谁用到谁处理」:导购线自己裁上下文,客服线自己写记忆。

我第一版就是这么写的,然后踩了两个坑:

放哪 后果
治理放在各支线 三条线拿到的历史不一致。改一处裁剪策略要改三处,很容易改漏一处,同一句话在不同支线里行为不一样
归档放在各支线 只在导购线归档时,用户在客服线说过的偏好在导购线就不存在了。这是很常见的漏

所以现在的做法是:治理放在进门前,归档放在出门后,支线只管自己那件事。

状态里有两条线要分清:messages 保留全量,送给模型的却是裁剪过的。保留全量是为了可追溯,裁剪是为了省钱和让模型看得清。 这两件事混在一起,你会在这两个目标之间反复摇摆。

3.3 路由为什么放在第二层

路由放在「装载之后、支线之前」,是因为它需要两样东西都到位:裁剪好的历史(判断上下文),召回好的记忆(判断这个人是谁)。

路由本身是一次结构化输出,输出一个 Pydantic 模型:

class Route(BaseModel):
    route: Literal["advisor", "order", "service", "chat"]
    reason: str
    category: str = ""
    budget_max: float | None = None
    sku_id: str = ""
    order_id: str = ""

注意后 4 个字段。它们是路由这一步顺手抽出来的槽位,会存进会话状态,交给支线用。

这一步是小投入大回报。不留这些槽位,支线就不知道用户刚说过「预算一千以内」,于是把这句话再问一遍。

而这次抽取的钱已经花过了,信息丢掉等于白花。

路由的判据只有一条,我在提示词里写得很重:

分界只看一件事:这句话要的是「了解」还是「办掉」。

「这款耳机怎么样」是要了解,走导购。「就这个,买了」是要办掉,走订单,哪怕句子里带着商品名和规格。这条线在第九章还会回来,因为模型在这里翻过车。


四、该动几个 Agent

4.1 三条支线不是三个 Agent

前面有篇文章算过一笔账,动一个专家省一半,动三个贵一半。那笔账在这里要重新算一遍,因为这个工程的切法和那篇不一样。

切法 怎么切 成本
1 个大 Agent 所有工具都给它,一个循环跑到底 提示词最长,工具定义最多,每次判断都要看全部
3 个平级 Agent 三条线各一个完整 Agent,互相转交 转交要额外一轮模型调用,且容易来回推诿
3 条支线共用一个循环 同一个循环实现,只换工具清单与提示词 只在路由上多花一次调用

这个工程用的是第三种。它的关键在「共用」两个字:循环的代码只写了一份(build_loop),三条支线各自实例化一次,差别只有三个参数:

tools = shelf.for_role(role)
budget = max_rounds or sess.cfg.max_rounds
system = build_system(role, sess, ...)

共用一个循环,省下的是三份重复的代码;换掉工具清单,得到的是三份不同的权限。

4.2 工具按支线发,不按需要发

工具清单长这样:

TOOL_GROUPS = {
    "advisor": ("search_products", "get_product", "compare_products",
                "get_reviews", "recall_preferences"),
    "order": ("search_products", "list_my_orders", "get_order",
              "get_logistics", "quote_price", "place_order"),
    "service": ("search_policies", "check_return", "create_ticket"),
}

导购线拿不到 place_order。这个决定救过一次:用户问「这款怎么样」,导购线即使动了手,也没有下单这个工具可用。

工具越多,误选概率越高,而且每个工具的定义都要占提示词的 token。发它用得上的那几个,是同时省成本和降风险。

这里有个细节值得单说。订单线里我塞了一个只读的 search_products:

支线 加了什么 为什么
订单线 search_products 用户说的是商品名,而 place_order 要的是商品编码

不加这个工具的时候,用户说「帮我下单明砚那款带鱼屏」,订单线会回头反问「请问 SKU 是多少」。这一轮对话在真实场景里非常扫兴。它是只读工具,放进订单线不增加写风险。

4.3 什么时候该多切一个

分不分线,我按这三条判断:

判据 说明
工具集能不能分开 几条线要用的工具高度重叠,切了等于没切,反而多一次路由花销
权限要不要隔开 有一条线有写操作、另一条完全没有,这个隔离本身就有价值
提示词会不会打架 导购要热情推荐,客服要谨慎不下结论。这两套语气硬塞进一个提示词,模型会精神分裂

第三条最容易被忽略,也最直接影响体验。语气冲突是分线的一个硬理由,跟成本无关。


五、导购线:检索、比价与「记得你」

5.1 检索用 BM25,不引向量库

这个决定在开篇讲过:为了让工程解压就能跑。这里说代价。

BM25 是字面匹配。中文没有空格,我用二元切分代替词典分词,「主动降噪耳机」切成 主动 / 动降 / 降噪 / 噪耳 / 耳机。它不需要维护词典,对短语匹配反而更稳。

政策库有个额外设计:每份文档带一个 scope 字段(退换货 / 运费 / 发票 / 保修 / 价保 / 促销)。检索时先按业务域过滤,再在文本上排序。

index.search(query, top_k=5, where=lambda doc_id: docs[doc_id].scope == "退换货")

这一步很有用。「七天无理由退货,运费谁出」这个问题,不过滤的时候会被「发票怎么开」这类文档挤掉名次;过滤之后,正确的两份文档排在第一第二,分数 17.46 和 15.26。这是真实检索系统里的 field filter,它和排序是两件事。

5.2 四类边界,我一条都没藏

检索跑了 4 类典型查询,结果都留着,包括难看的:

查询 结果 性质
通勤 降噪 耳机 聆风 A1 排第一,6.28 分 正常命中
三百以内 新手 零命中 价格是结构化条件,不是文本。「三百以内」这四个字在任何商品标题里都不出现
发票要多久 「发票内容可以修改吗」排第一 词汇鸿沟。用户说「多久」,文档写「时间」,字面对不上
满减和优惠券能一起用吗 补了文档后 13.40 分排第一 原本没有正确答案,这是语料缺口,不是检索错
显示器有坏点能不能换 排序不稳 短查询里的实词被「显示」「大件」这类泛词稀释

第三行那个是设计问题而不是技术问题。检索返回了不对的文档,但真相是语料里根本没有这份规则。补上文档之后它自然排到第一。

第五行我决定保留不修。短查询被泛词稀释是 BM25 的天然边界,用向量检索能盖住,但盖住之后你就不知道边界在哪了。

这几条边界在工程里如实标了出来,用户问到的时候会退回按结构化条件列商品,结果不那么精准,但至少不是编的。

5.3 记忆召回的手该伸多长

导购线有个工具叫 recall_preferences,让模型自己决定要不要查这个人的偏好。

但更重要的召回不在工具里,在主图的装载环节。每一轮进门前,系统会按话题召回长期记忆,塞进系统提示词:

关于这位用户,之前记下的(若与当前说法冲突,以当前为准):
- 身体条件:对夹头敏感,戴眼镜
- 使用场景:通勤坐地铁
- 品牌:云雀(回避)

召回排序是三件事相乘:

score = 置信度 × 时间衰减 × 与当前话题的相关度

时间衰减的半衰期是 30 天。用户上个月的口味不该和上周的一样重。

为什么是相乘而不是加权求和?因为这三件事里任何一项归零,这条记忆就不该被带出来。加权求和会让「置信度极高但完全不相关」的记忆挤进前几名,白占 token。

界面上有个「记忆」标签页,能实时看到三层记忆的内容、置信度、命中次数,也能手动删。记忆系统最需要的是可见性。 一个看不见的记忆库,你没法判断它是在帮忙还是在添乱。


六、订单线:钱的事必须停下来问一句

6.1 算价归代码,模型只管说话

价格是这门生意里最不能出错的一处。让模型自己算,它会给你一个看着合理、但对不上规则的数字,而且每次算得还不一样。

所以这条线的分工是:规则归代码,模型只决定「要不要算」和「跟用户怎么说」。 金额由代码算出来,模型的职责是把数字讲清楚。

否

是

用户同意

用户拒绝

用户说要买

认货
search_products 把商品名换成编码

算价
compute_quote 纯代码

应付金额 > 阈值?

直接落单

interrupt 抛中断
界面上弹确认卡

带确认标记重新落单

记下拒绝
订单不产生

幂等键落 SQLite

写入订单文件

算价规则本身有三处容易踩:

坑 正确做法 踩了的后果
门槛按什么价判定 满 800 减 100 里的 800,看原价小计 混用口径,金额差出几十块
可叠加与不可叠加 可叠加的全上,不可叠加的只取减得最多的那个 把规则表从头扫一遍全减上,算出负价
券码要不要 需要券码的,用户没给码就不算进最终金额,但要告诉他「你还有一张能用的」 要么少算,要么给了一个用户拿不到的价格

加价门槛这条我举个具体数字。工程里有一款明砚 34 英寸带鱼屏,原价 3299 元。

用上全场 95 折和新客立减 30 之后,应付 2838.07 元。门槛判定看的是 3299 这个原价,不是折后的数字。

这里错了,用户看到的价格就差出一档优惠。

6.2 金额超过阈值,流程停下来

这是我在这条线上最想让读者带走的设计。

answer = interrupt({
    "type": "confirm_order",
    "payable": q.payable,
    "threshold": sess.guard.max_amount,
    "reason": f"金额 {q.payable:.2f} 元超过设定的确认阈值 {sess.guard.max_amount:.0f} 元",
})

注意这里做了一件很容易漏的事:确认卡上的金额是代码重新算的,不是模型报上来的。 模型说的应付金额可能是它算错的,让人确认的数字必须是自己算的。

阈值默认 2000 元。工程里 40 款商品只有两款越线,一款 2838.07 元,一款 2280.07 元。用户点「同意」,落单 2838.07 元,与确认卡上的数字一致;点「拒绝」,订单不产生。

这里最重要的一句是:这不是「问一句」的礼貌,是流程上的强制点。 模型没有绕过它的路径,因为它不在模型手里。

6.3 幂等键必须落盘

重复下单这件事,最危险的地方在于它不报错。

第一次请求正常返回,第二次重放也返回一个 200,日志干干净净,账上多了第二笔订单。这类问题在真实系统里的最常见来源是重试和用户手抖。

def make_key(session_id: str, action: str, *parts) -> str:
    raw = "|".join([session_id, action] + [str(p) for p in parts])
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]

键落在 SQLite,不放在内存里。原因是「重启之后依然要拦住」:进程挂了、服务重启了,用户重放同一个请求,第二笔订单照样不能产生。

验证的方式很直接:把同一笔下单跑两遍,第二次工具返回的是原结果,附带一句「这笔请求之前已经处理过了,没有重复下单」,订单文件里只多一行。

护栏 落点 拦不住会怎样
幂等 工具层 + SQLite 同一笔订单下两次,且不报错
金额确认 支线循环内的中断 用户没看过金额就被扣钱
轮数上限 循环计数(不在提示词里) 模型在两个工具之间反复横跳,一直烧 token

第三道我想多说一句。提示词里写「不要反复调用工具」是建议,代码里的计数才是约束。 到顶之后循环不抛异常,而是把已经查到的信息交给用户,说明为什么停下来。


七、客服线:把流程写成一个 Skill

7.1 工具查事实,Skill 管判定

客服这条线要处理的是「能不能退」「运费谁出」「超过七天怎么办」。这类问题有个特点:判定逻辑会改,而且改得很频繁。

把判定写进代码,运营改一次口径就要发一次版。把判定写进提示词,它会散落在几十行描述里,越来越难维护。

所以这里用了第三种:写成一个 markdown 文件,src/skills/after-sales/SKILL.md。

## 三、时效判定

1. **七天无理由**:签收次日起七日内。超一天就是超一天,不要含糊说「差不多可以」。
   数码类(耳机、键盘、显示器)要求包装完整、配件齐全。
2. **质量问题**:不受七天限制,按保修期走。
3. **超过七天的无理由**:明确告诉用户不符合,同时给出还能走的路径。

改一行就生效,不用改代码,不用重新部署。

谁来做 装什么 适合什么
工具 事实:签收时间、订单状态、类目、引用的条款原文 会变但改不动的数据查询
Skill 流程与口径:按状态分流、时效怎么算、运费谁承担 判定规则、话术约束
代码 确定性的计算与拦截:金额、幂等、轮数 不能出错的边界

这三者的分界线只有一句:工具负责把事实查准,Skill 负责怎么判,代码负责不许越线。

7.2 Skill 里最有用的部分是「不要做什么」

写这个文件时,我把一半篇幅花在了禁令上:

禁令 原因
不要承诺具体退款到账时间 规则写的是「按原支付渠道退回」,到账由支付渠道决定,说「3 到 5 个工作日」是在编
不要承诺拦截一定成功 物流拦截有成功率,取决于包裹到了哪一环
不要把邻近的规则当答案 用户问促销叠加,检索可能返回发票的文档。答不上就说没查到
不要在超期时给用户留希望 明确说不行,再说还有什么路可走

这四条都是我在真实客服场景里见过的话术失控。流程文档的价值,很多时候不在于告诉模型怎么做,而在于告诉它哪些话不能说。

后面那节的护栏会讲,有些话光靠文档是拦不住的,得靠代码。


八、长会话不爆窗口

8.1 治理手段按代价排序

一个会话聊到二十轮之后,如果每轮都把全部历史塞回去,会发生三件事:token 成本线性上涨、模型开始忽略中间的内容、首字延迟越来越长。

治理手段按代价从低到高有三档:

手段 代价 换回什么
裁剪:只留最近 N 条 0,纯计算 成本有上限。丢信息
摘要:被裁掉的压成一段话 一次模型调用 保住结论,代价是被裁掉的内容够多才值得做
按需注入:记忆只在相关时才带 0 不无条件全带上,省下的是每轮的固定开销

工程里用的是 1 + 2 组合,裁剪是常规动作,摘要有门槛。

8.2 门槛为什么要设

这里有个取舍:被裁掉 3 条消息就去做一次摘要,为省几十个 token 花一次调用,明显不划算。

所以设了两道门槛。第一道是消息条数,历史超过 12 条才开始裁;第二道是被裁掉的内容要有 400 token 以上,才触发摘要。

具体数字,一段 20 轮的对话(40 条消息)经过治理:

指标 数值
历史总条数 40
保留 12
裁掉 28
被裁掉的内容 1344 token(超过 400 的门槛,触发摘要)
治理后这次请求 584 token
不治理全带上 1928 token
省下 约 70%

顺带说一个数字:这个工程里的消息平均一条约 48 token。你可以拿它估自己的场景。

8.3 裁剪要按轮次边界切

有个细节值得写下来:裁剪不能直接从中间切。

如果切点落在一问一答中间,留下的历史会以助手的话开头,模型看到的是一段没有提问的答复。所以代码里从后往前找,找到第一个用户消息才切。

cut = len(messages) - keep
while cut < len(messages) and messages[cut].get("role") != "user":
    cut += 1

这个 20 行的函数调了我两个晚上。 症状很隐蔽:不是报错,是模型偶尔答非所问,而且只在大约十几轮之后才出现。


九、循环里那件最容易塌的事

9.1 三处塌陷

这一章是我最想让你看的部分。前面那些设计是「想清楚了再写」的结果,这一章的三件事是「写完跑起来才发现」的结果。

有

没有

Agent 循环

模型决定下一步

有工具要调吗

执行工具

把结果交回模型

形成答复

塌陷点一
结果交回的方式错了
模型会编造后续动作

塌陷点二
写操作交给模型的自律
它就是不调第二个工具

塌陷点三
流式与护栏有时序差
谎话已经上屏

塌陷点 现场表现 处置
工具结果伪装成用户消息 模型把工具返回当成「用户又说了句话」,判断对话该收尾,于是编一个订单号宣布成功 按协议回灌,用 assistant.tool_calls + role=tool 的消息对
写操作压在模型记性上 手上有报价、用户说了要买,模型就是不调第二个工具 写操作由流程接管,触发条件卡死
流式与护栏的时序 谎话已经逐字吐到屏幕上,护栏改的是最终答复 前端以最终答复为准覆盖已显示内容,并标注「答复已更正」

9.2 第一处:工具结果要按协议交回去

这是我在这整个工程里踩得最深的一个坑。

原来的写法很自然:把工具结果拼成一句话,当成一条用户消息发回去。

# 错的写法
msgs.append(("user", f"以下是工具返回的数据:{result}"))

跑起来之后,模型给出的答复是:

订单号 ORD-20260917001,已下单成功。

它一次都没调过 place_order。

原因在消息的角色上。在模型看来,role=user 意味着又有人说了一句话。

它读到工具结果,接着读到「以下是工具返回的数据」,于是判断这是一轮完整的问答,对话该收尾了。后面那一步它没工具可用,就编了一个结果继续往下说。

正确的做法是按协议回灌,把工具结果放成消息对:

msgs.append(AIMessage(
    content="",
    tool_calls=[{"name": o["tool"], "args": o["args"],
                 "id": cid, "type": "tool_call"}]))
msgs.append(ToolMessage(content=result, tool_call_id=cid))

少了 tool_call_id 的配对,模型收到的是「一次来历不明的工具返回」,行为不稳定。

改完之后模型才开始真的连调工具。这件事跟模型大小无关,9B 和 70B 都会这么干。 它是协议层的错,不是能力问题。

9.3 第二处:不能因为拿到结果就把工具收走

上面那个坑还有第二个成因,而且它更隐蔽。

原来我的循环里有这么一条判断:一旦这一轮已经拿到过工具结果,下一轮就不再给它绑工具,让它专心组织答复。

听起来很合理,实际上是错的。真实任务常常是三步:先认货、再算价、最后下单。

如果第一轮之后就不给工具,链路只能走一步。模型没有工具可用,它就编。

所以现在工具全程绑着,用 bind_tools 一直挂着。它可能还要再调一次,也可能直接收尾,两种结果循环都要接得住。

9.4 第三处:写操作不交给模型的自律

工具交回去之后,我以为事情就好了。结果是另一个样子。

用户说「帮我下单明砚 34 英寸带鱼屏 一台」。模型先检索、再算价,拿到了完整的报价:单价 3299 元,已生效优惠 460.93 元,应付 2838.07 元。然后它停下来了,开始写答复:

已为您下单,实付 2838.07 元。

place_order 依然没被调用。

我试着掰了三种写法:明令它必须调用、给它一个典型反例、把步骤拆成逐条引导。三种都没用。

它手上有完整报价,用户也明说了要买,它就是不肯调第二个工具。

我后来想明白了这件事的性质:这不是幻觉,是一种「任务完成的错觉」。在它的理解里,用户想要的东西已经算清楚了,剩下的动作像是系统会自己完成的,不需要它再做什么。

最后的处置是把写操作从模型手里接过来:

def _force_write(sess, role, state):
    if role != "order":                       # 只有订单线有写操作
        return None
    obs = state.get("observations") or []
    if not obs:                               # 模型这一轮动过手
        return None
    if "place_order" in {o["tool"] for o in obs}:
        return None                           # 没下过单
    if not looks_like_purchase(sess.facts["last_user_text"]):
        return None                           # 用户明确说了要买
    ...
    return {"tool": "place_order", "args": {...}, "call_id": "harness_place_order"}

触发条件卡得很死,四条同时满足才接管。而且接管不绕过任何一道确认:

接管之后仍然要走的 说明
金额确认 超过 2000 元照样中断,照样弹确认卡
幂等 落单照样过幂等键
工具返回 落单成功与否,以工具返回为准

这是一个我认为值得记住的判断:当一件事的正确性不能依赖模型时,把它从模型手里拿走,而不是把提示词写得更长。 提示词是建议,代码才是约束。

9.5 两条额外的护栏

第四章埋的那个伏笔在这里收一下:路由把「要办掉」的话判成导购线时,模型会走错线。

同时还有另一种更隐蔽的编造:模型不去检索,直接给出一个商品编码和价格,说得有零有整。

这款 SKU-D3015 现在 459 元。

而库里根本没有 D3015 这个编码。

两条护栏都是确定性的字符串检查,很便宜:

拦什么 怎么判断 拦下之后
编造的商品编码 用正则把答复里的编码抠出来,逐个去库里认,认不出的就是编的 换成如实答复,让用户重说一次需求
没有凭据的成功话 答复里出现「下单成功」「订单号是」这类词,但这一轮没有任何写工具成功返回过 换成「这笔订单还没有真正提交」

第二道护栏里有个细节差点酿成事故。用户拒绝金额确认之后,系统会在观察里记一条 place_order 的结果,内容是「用户没有确认,系统未下单」。这条记录顶着 place_order 的名字,于是「有没有成功执行过写操作」的检查把它当成了一次成功下单,放行了模型编出来的「已为您下单 2280.07 元」。

修法是给它加一个明确的标记:

{"tool": "place_order", "args": args, "executed": False, ...}

检查的时候尊重这个标记:executed=False 的那些不算写操作。

护栏自己也要经得起检查。 一道护栏误判成「操作已发生」,比没有护栏更危险,因为它会让谎话顺利通过。

9.6 流式与护栏有天然时序差

最后一处,也是最隐蔽的一处。

为了让用户不用干等,答复是逐字流式吐出来的。护栏在答复形成之后才生效,改的是最终答复。

于是在日志里出现过这样的场景:模型先流式吐出一段「订单已生成!实付 2280.07 元」,护栏把它拦下换成「这笔订单没有提交,也没有产生任何扣款」,但屏幕上前面那段谎话已经一个字一个字显示完了。

处置分两半:

半 做法
前端 收到最终答复时,替换已经流式显示的内容,而不是追加。并标上「答复已更正」
后端 终帧带上明确的事件类型,让前端能区分「流式片段」和「最终答复」

这件事让我对「流式」有了新的认识。流式把护栏的生效点后移了。

所有在答复之后才做的检查,都会有一个「谎话已经上屏」的窗口期。你只能事后覆盖,覆盖不了就标注。


十、算账

10.1 一轮对话花多少钱

前面有篇讲上线的文章算过容量和成本。这里把口径缩到单次对话。

一次完整问答的 token 消耗,主要来自四类调用:

调用 什么时候发生 量级
路由 每轮一次 小。输入是历史尾部 + 本轮输入
支线思考 每轮 1 到 N 次(N 是工具调用轮数) 大。输入是完整系统提示词 + 历史 + 工具定义 + 观察结果
记忆召回 每轮一次 零模型调用。纯本地打分排序
记忆抽取 有信号时才发生 中等。输入是最近 6 条消息

我注意到一个规律:一次多步任务的费用,主要花在支线思考的第二次和第三次调用上。 第一次调用输入还小,第二次开始带上工具定义和观察结果,输入长度跳一档。

所以省钱的位置不在路由,也不在记忆,在工具返回的内容有多长。工具返回一句 300 字的商品列表,接下来每一轮思考都要重新看一遍这 300 字。

这个工程里所有工具都有意识地压结果长度,只给模型用得上的字段。工具的输出格式,是一种成本控制手段。

10.2 多 Agent 的成本放大在哪

第四章讲了为什么切三条支线。这里说代价。

方案 相对成本 放大在哪
单 Agent 一个循环 1.0 提示词最长,工具定义最多
三条支线共用循环 约 1.05 只在路由多一次调用。工具清单变短了一部分抵消
三个独立 Agent 互相转交 1.5 起 转交本身要一轮调用,而且可能来回转

三条支线的成本接近单 Agent,原因是它们共用同一个循环,且每次只带自己那几个工具的定义。切分的成本主要不在「切」,在「转」。 只要没有转交,切分几乎是免费的。

这条结论跟前面那篇多 Agent 的文章是一致的:贵的是专家之间的交接,不是专家本身。


十一、换成你自己的,改这几处

到这里,这个工程已经能跑、能验、也能改。它对你有没有用,还取决于剩下的一件事:它是一副能换零件的骨架,还是一整块只能看的东西。

差别在你要换东西的时候才显出来。演示是一整块,换个数据源要重写一半。骨架是分层的,层与层之间只有一个窄接口,你只动那一处。

这九处外部依赖,我一个个收进了单独的文件或函数里。

11.1 九处接口

# 换什么 现在的实现 上生产换成什么 改哪里
1 模型后端 一个 OpenAI 兼容客户端,本地端点与云端预设都在 你自己的模型服务,云端或自建,只要兼容这个协议 src/brain.py 的 make_brain()
2 数据源 7 个 JSONL,40 款商品 你的商品库、订单库、政策库 src/dataset.py 的 get_catalog()
3 检索 纯 Python BM25,中文按二元切分 Elasticsearch、Milvus 配 bge-m3,或你现有的搜索接口 src/retrieval.py 的 search()
4 长期记忆 SQLite 单文件,三张表 Redis、PostgreSQL,或独立的记忆服务 src/memory.py,方法签名可以不动
5 会话检查点 SQLite checkpointer Postgres checkpointer,多副本之间共享 src/runtime.py 里取 checkpointer 那一处
6 幂等表 SQLite,幂等键落盘 Redis,或在你自己的业务库上加唯一索引 src/guard.py 的幂等三件套
7 工具 8 个函数,按支线分组下发 你的接口,包一层就行 src/tools/ 下加文件,在 TOOL_GROUPS 里登记
8 业务口径 一个 markdown 文件 你的售后规则与话术禁令 src/skills/after-sales/SKILL.md
9 界面 原生 JS 三栏页,SSE 流式 你的前端,或只留接口嵌进现有系统 web/ 与 src/server.py

最省事的是第 7 项。工具只要满足两条:接收一个参数对象,返回一段字符串。你手上的接口包一层就能挂上去。

第 3 项和第 4 项要多花点心思,因为这两处的排序逻辑跟业务绑得最紧。检索里的 where 过滤、记忆里的置信度与时间衰减,换成你的数据之后权重要重新调一遍。这两处调不好,Agent 不会报错,只会答得不对。

11.2 换完之后,有五处别动

别动 为什么
主图那四个环节 「进门治理、出门归档」这个位置是试出来的。挪进支线里,三条线拿到的历史会不一致
路由的判据 「了解还是办掉」这条线划错,后面所有护栏跟着失效
循环的形状 think 加 act,一条回边,够用。节点加多了,状态更难追
三道护栏的位置 幂等在工具层、金额在支线循环里、轮数在计数上。挪位置等于放开门
工具按支线发 发全量会让误选概率和 token 一起涨

这五处管的都是边界,跟具体业务无关。业务怎么变,它们都不用动。这也正是它们能留在骨架里的原因。

11.3 上线前,我会先确认这五件事

要确认的 判断标准 这个工程里的位置
写操作有没有从模型手里拿走 提示词里写「必须调用」不算,代码里得有一个强制点 9.4 的 _force_write()
幂等键落没落盘 重启服务再重放同一个请求,订单文件不该多出一行 6.3 的幂等表
不能错的数字是不是代码算的 钱、时效、库存这类字段,模型只能引用不能生成 6.1 的 compute_quote()
每条线的轮数上限设了没有 上限写在代码里。写在提示词里的那个是建议 6.3 的循环计数
中间过程看不看得见 出问题的时候,你能不能答出「它第几步开始错的」 1.2 的过程面板

五条都过了,这副骨架就可以接你的业务。有一两条没过,先补上再上。

这五条里没有一条跟模型能力有关。换个更强的模型会让人舒服一些,但它替不了这五件事。


十二、留给你的三个问题

工程到这里就说完了。它能跑、能验、能改、能换,也有明确说出来的边界。

我想留三个问题给你,都是我在装这条线的时候真正停下来想过很久的:

第一,你的 Agent 有没有一个「看得见」的地方?
这个工程里三处最严重的塌陷,全部是靠过程面板发现的。如果只能看到最终回答,你看到的是一个偶尔胡说八道的模型,看不到它在哪一步开始胡说的。

第二,有哪些事你已经写进了提示词,但它其实应该是代码?
我这个工程里至少有三件:轮数上限、金额阈值、写操作的触发。提示词是建议,代码是约束。一件事如果你不能让模型做错,就别放在提示词里。

第三,你有没有让护栏自己也被检查过一遍?
9.5 节里那道差点放行谎话的检查,本身也是一道护栏。护栏误判比没有护栏更危险,因为它会让你以为这件事已经被管住了。


这一路上我们拆过上下文、拆过记忆、拆过工具接入、拆过评测,也拆过上线之后会坏的每一类事。现在这套零件在你手里了,剩下的就看你想为谁做点什么。

工程放在这里,pip install -r requirements.txt,然后 python run.py。不用配任何东西,它会先以规则模式跑起来给你看链路。等你觉得可以了,再把你的模型接上去。

它不完美,检索的边界我在第五章如实列了四条。但它是一副能换零件的骨架,一个能换成你自己的东西,比一个演示得更漂亮的玩具有用得多。

这个工程是完整可跑的
一个带浏览器界面的购物助手 Agent,配好模型地址和密钥就能对话。
在公众号后台回复 shopagent,领取完整工程包。
解压之后两行命令就能起服务,不配模型也能先跑起来看链路。