别把产品决策交给模型:从工具路由到短期记忆
Fawn 是我给自己家宝宝做的育儿 agent。它一开始不是一个很宏大的东西,也不是为了展示某个框架能力。更具体一点,它要能听懂家里人说的话,然后帮我们把育儿里的琐碎数据记下来:喂奶、睡眠、生长、健康、宝宝档案、照片,还有家庭成员之间不同的权限。
今天在开发过程中。
测试说:
刚刚喝了91ml配方奶
我希望管家Agent真的写入一条喂养记录,而不是礼貌地回一句“好的,我记下了”。如果写错了,或者在没有权限的时候写了,后果也不是一句聊天回复不好看那么简单。它会污染真实的家庭数据。
我最早想解决的问题是:怎么让 agent 在该调用工具的时候,真的调用正确的工具?
后来做着做着,我发现这句话只说对了一半。工具路由当然要做,但真正的问题不是“模型能不能更聪明一点”。真正的问题是:一个 agent 应用必须有一层可靠的工程外壳,把模型的语言理解和产品里的决策分开。
也就是大家所谓的 Agent Harness Engineering。
模型可以判断“这句话像是在记录配方奶,数量是 91ml”。但后端必须决定:当前用户有没有权限、字段是否完整、是否需要确认、应该调用哪个 service、结果怎么返回、这次操作如何测试。
这篇文章就是一次从工具路由走到短期记忆的工程复盘。
模型负责理解语言。
Harness 负责产品决策。
第一版问题:工具调用不能靠模型自觉
Fawn agent MVP 是 LangGraph ReAct 风格。模型拿到用户消息后,可以选择调用工具。工具列表里有记录类、查询类、宝宝档案、相册等能力,例如:
record_feedingrecord_sleeprecord_growthrecord_healthupdate_tracker_recorddelete_tracker_recordquery_feeding_dataquery_sleep_dataget_baby_profilebrowse_photos
这种模式很适合开放式聊天。用户问一个宽泛问题,模型可以自己规划下一步,必要时查资料、读档案、调用工具。
但结构化业务不是这么回事。
还是那句“刚刚喝了91ml配方奶”。它在产品里对应的流程大概是:
- 判断这是喂养记录。
- 提取喂养类型:配方奶。
- 提取数量:91ml。
- 把“刚刚”锚定到当前时间。
- 检查当前用户有没有写 tracker 的权限。
- 调用 tracker service 写入。
- 通过 SSE 把工具调用和结果返回给前端。
- 保存最终 assistant 消息。
这不是一段闲聊,而是一条产品工作流。把它完全交给模型自由发挥,迟早会出问题。
所以第一步不是继续堆 prompt,而是在 LangGraph 前面加一层 deterministic tracker routing。
大致流程是:
用户消息
-> 结构化 tracker intent classifier
-> tracker orchestrator
-> tracker service
-> SSE 响应
-> 如果不是 tracker,再 fallback 到 LangGraph
这里的 classifier 仍然可以用 LLM。它擅长从自然语言里抽取意图和字段,没有必要把所有中文表达都写成正则。但它必须输出结构化结果,比如:
- intent
- slots
- missing slots
- confidence
- 是否需要确认
后端拿到结构化结果后,再做确定性判断。
这一步的分工很重要:
模型:这句话像是在记录配方奶,数量是 91ml。
后端:当前用户有权限,字段完整,可以调用 create_feeding_record。
prompt 可以提醒模型“请调用正确工具”,但 harness 才应该真正决定“能不能调用、调用哪个、参数是否合法、是否允许写入”。
先把可控性测出来
为了避免靠 live model 的运气,我给这层路由补了测试。测试里可以 mock classifier,让它返回固定的结构化 intent,然后断言后端行为。
第一批 harness 测试覆盖了几个场景:
- 喂养记录能写入,并返回
record_feeding工具事件。 - 朋友权限不能写入 tracker。
- 缺字段时不乱写,而是追问。
- 查询睡眠为空时,返回合理的空结果。
- 不是 tracker 的消息,会 fallback 到 LangGraph。
这一步之后,“完整的一句话来了,怎么保证它走正确工具”这个问题基本稳住了。
然后很快,手测把另一个问题暴露出来了。
睡眠记录让我意识到:路由不是全部
我在测试睡眠记录时,遇到过一个坏流程:
用户:昨晚睡眠7个小时
管家:请补充开始时间和夜醒次数
用户:差不多,醒1次
管家:仍然追问睡眠时间
用户:8点到3点
管家:理解成今天 08:00 到 15:00 的小睡
用户:不是,晚8点到凌晨3点
管家:把它当成修改已有记录,列出很多候选
单独看每一句,系统的反应似乎都有一点道理。
“差不多,醒1次”确实不是一条完整睡眠记录。“8点到3点”也可能是上午 8 点到下午 3 点。“不是,晚8点到凌晨3点”如果脱离上下文,也有点像在纠正某条已有记录。
但人不会这么理解。
放回上下文里,用户只是在补同一条睡眠记录:
昨晚睡了7小时
醒1次
晚8点到凌晨3点
确认
这次失败让我意识到,我之前解决的是“当前这句话应该走哪个工具”。但真实对话里,用户很少每次都说完整句。尤其在家庭育儿这种场景里,人说话更像碎片:
刚刚喝了90
配方奶
不是,91
对,就现在
如果系统只看当前这一句,它就会像一个每句话都第一次见到你的客服。
于是问题从工具路由升级成了短期记忆。
三件事不能混在一起
我后来把这块拆成了三件事。
第一件事是工具路由。
它回答的是:
这句话应该调用哪个能力?
第二件事是短期上下文。
它回答的是:
用户这句话接着前面哪句话说?
第三件事是 Pending Task。
它回答的是:
系统现在正在办哪件事?已经收集了哪些字段?还缺什么?能不能写入?
这三件事很容易被塞进同一个“memory”概念里,但我现在觉得那样会把系统做糊。
只有工具路由,没有短期上下文,agent 只能处理完整句子。
只有上下文,没有 Pending Task,模型也许“理解了”,但后端没有一个可测试、可恢复、可过期的任务状态。
只有 Pending Task,没有普通 recent context,普通聊天和轻量追问又会显得很僵。
所以 Fawn 的短期记忆应该分成两层:Recent Context 和 Pending Task。
Recent Context:让 agent 知道刚刚聊过什么
Recent Context 是最基础的短期记忆。它不是专门为了某个工具服务,而是为了让管家像一个正常聊天对象。
我倾向于每次 chat 请求都从 messages 表加载最近 10 轮 user-assistant 对话,最多 20 条消息。这里不要只依赖 LangGraph checkpoint。messages 是产品侧可见、可查、可测试的事实源,它天然就是最近对话的来源。
每条 recent context 最好带上这些信息:
- 绝对时间
- 相对时间
- 说话人
- 权限类型
- 家庭角色
- 简短内容
例如:
<recent-context>
[2026-05-04 20:15 | 3分钟前 | 妈妈/父母 | 角色: 妈妈] 昨晚睡眠7个小时
[2026-05-04 20:16 | 2分钟前 | 管家] 大概几点到几点?夜醒几次?
[2026-05-04 20:17 | 1分钟前 | 妈妈/父母 | 角色: 妈妈] 差不多,醒1次
</recent-context>
当前用户消息:
不是,晚8点到凌晨3点
时间戳在育儿场景里非常重要。“刚刚”“昨晚”“今天早上”“凌晨3点”都依赖时间锚点。没有这些锚点,模型很容易把“8点到3点”理解成白天的小睡。
这层 context 应该同时喂给 deterministic task orchestrator 和普通 LangGraph fallback。
原因很简单:短期上下文不只属于 tracker。就算用户是在普通聊天里问“那刚才那次算多吗”,管家也得知道“刚才那次”指的是什么。
Pending Task:把正在办的事放进后端
Recent Context 解决“听懂上下文”,但它不能解决“任务状态”。
比如睡眠记录这件事,系统需要明确知道:
- 当前正在补一条 sleep record
- 已知睡眠时长是 7 小时
- 已知夜醒 1 次
- 还缺开始和结束时间
- 跨天睡眠需要确认
- 1 小时后这个任务应该过期
- 最终确认人是谁
这些信息不能只藏在 prompt 里。它应该存在后端表里,也就是 agent_tasks。
一个 pending sleep task 可以长这样:
{
"domain": "tracker",
"action": "create",
"record_type": "sleep",
"slots": {
"duration_hours": 7,
"night_wakings": 1,
"sleep_start": "2026-05-03T20:00:00+08:00",
"sleep_end": "2026-05-04T03:00:00+08:00",
"sleep_type": "night"
},
"requires_confirmation_reason": "cross_day_sleep"
}
第一版可以保持克制:每个家庭同一时间最多一个 active task,1 小时过期。
这个规则当然不完美。一个家庭里可能同时有人在补睡眠记录,有人在改喂养记录。但在当前产品里,“一个家庭共享一个聊天窗口”才是主要形态。先把最常见的链路做稳,比一开始设计一个复杂的多任务系统更重要。
任务状态可以很朴素:
pendingawaiting_confirmationcompletedcancelledexpired
任务上还可以记录谁发起、谁最后补充、谁确认。这样它就不是一段 prompt 里的隐含上下文,而是产品系统里的工作记忆。
这比把状态塞进 messages.metadata 更像一个正式系统:好查、好测、好过期,也方便以后扩展到宝宝档案、相册或家庭管理。
为什么不直接依赖 LangGraph checkpoint
LangGraph checkpoint 有用,我不想把它说成没价值。它适合保存 graph runtime state,让工具循环和多步推理能延续下去。
但它不是产品级短期记忆的完整答案。
原因有几个:
- deterministic route 可能在 LangGraph 前面就拦截了消息。
- checkpoint 不适合承担权限、过期、确认、审计。
- 它不容易在产品测试里直接断言。
- 它不应该成为“这个任务还缺什么字段”的事实源。
更合理的分层是:
messages
= 永久聊天记录,也是 recent context 的来源
agent_tasks
= 结构化 working memory,保存当前正在办的任务
LangGraph checkpoint
= graph 运行态,第一版继续保留
profile / summary
= 长期记忆,不承担当前任务状态
Hermes Agent 在这一点上给过我启发。它没有把所有东西都塞进一个叫 memory 的模块,而是拆成 transcript、context engine、memory provider 和任务状态。Fawn 不需要照搬那套插件系统,但分层这个思路是值得学的。
记忆不是越多越好。记忆要能回答具体问题。
Recent Context 回答“刚刚聊了什么”。
Pending Task 回答“现在正在办什么”。
Profile 和 summary 回答“这个家庭长期是什么样”。
Checkpoint 回答“当前 graph 跑到哪一步”。
这几个问题不是同一个问题。
状态机比长 prompt 更可靠
有了 Recent Context 和 Pending Task 之后,chat 请求的顺序就应该变得固定。
我不会把它写成完整技术方案,但大方向是这样的:
1. 保存当前用户消息
2. 加载 recent context
3. 加载家庭当前 active task
4. 检查朋友权限限制
5. 如果有 active task,判断是补充、纠正、确认、取消还是切换话题
6. 如果没有 active task,判断是否是在纠正最近记录
7. 判断是否是新的 tracker/profile 任务
8. 都不是,再 fallback 到 LangGraph 普通聊天
这背后的原则是:模型可以给判断,但状态机要做裁决。
一些写入规则也应该明确下来:
- 低风险、字段完整的记录可以直接写,比如“刚刚喝了90ml配方奶”。
- 跨天睡眠需要确认。
- 健康事件需要确认。
- 修改和删除永远需要确认。
- 没有 active task 时的“不是,是90ml”可以查找最近候选,但不能静默修改,必须确认。
- 宝宝档案里的高风险字段,比如生日、性别、出生体重,需要确认。
- 朋友可以查询和聊天,但不能创建、补充或确认写入任务。
这些规则听起来不性感,但它们决定了 agent 会不会像一个可靠的家庭管家。
很多 agent demo 看起来很聪明,是因为它们只需要把话说顺。真正接进产品数据之后,系统必须知道什么时候该停、什么时候该问、什么时候不能写。
测试要覆盖“人会怎么说话”
这类系统不能只靠手测。手测很重要,但手测发现问题之后,问题要被固定成回归测试。
Recent Context 至少要测这些:
- 能取最近 10 轮。
- 包含绝对时间和相对时间。
- 包含说话人。
- 不包含当前用户消息。
- 普通 LangGraph fallback 也能拿到它。
睡眠 Pending Task 要测多轮补全:
昨晚睡眠7个小时
差不多,醒1次
8点到3点
确认
期望结果是:
- 创建一个 sleep pending task。
- 合并夜醒 1 次。
- 把时间理解为昨晚 20:00 到今天 03:00。
- 因为跨天,先确认。
- 用户确认后只写入一条睡眠记录。
主动纠错也要测:
不是,晚8点到凌晨3点
如果此时有 active sleep task,系统应该修正这个 task,而不是列一堆无关候选。
没有 active task 时,还要测 bounded correction:
不对,是90ml
这时系统可以查找最近可能的喂养记录,创建一个等待确认的 update task,但不能静默修改。
权限和过期也不能漏:
帮我记录90ml配方奶
如果用户是朋友,系统应该拒绝写入,不创建写入类 task。查询和普通聊天仍然可以继续。
确认
如果旧 task 已经过期,系统不能执行旧任务,而应该让用户重新说明要记录什么。
这些测试不是为了追求覆盖率好看。它们是在逼系统承认一件事:真实用户不会按照表单字段说话。
我最后真正想留下的原则
这次从工具路由做到短期记忆,我对 agent 应用的看法变得更硬了一点。
如果只靠模型,系统会显得聪明,但很难控制。
如果只有规则,没有模型,系统又会像一张会说话的表单。
真正可用的 agent 应用,大多要走中间那条路。
让模型负责自然语言里最难规则化的部分:意图、指代、补充、纠正、模糊表达。
让 harness 负责产品里不能含糊的部分:权限、状态、确认、过期、审计、测试、写入。
所以,短期记忆也不是“让模型多记一点”。
一个家庭管家的短期记忆,至少要能回答这些问题:
- 刚刚聊了什么?
- 现在正在办什么?
- 已经收集了哪些字段?
- 还缺什么?
- 谁有权确认?
- 什么时候该停?
这才是我现在理解的 Agent Harness Engineering。
它不负责让模型看起来更神奇。它负责让模型接入真实产品后,不至于把事情办飞。
Enjoy Reading This Article?
Here are some more articles you might like to read next: