别把产品决策交给模型:从工具路由到短期记忆

Fawn 是我给自己家宝宝做的育儿 agent。它一开始不是一个很宏大的东西,也不是为了展示某个框架能力。更具体一点,它要能听懂家里人说的话,然后帮我们把育儿里的琐碎数据记下来:喂奶、睡眠、生长、健康、宝宝档案、照片,还有家庭成员之间不同的权限。

今天在开发过程中。

测试说:

刚刚喝了91ml配方奶

我希望管家Agent真的写入一条喂养记录,而不是礼貌地回一句“好的,我记下了”。如果写错了,或者在没有权限的时候写了,后果也不是一句聊天回复不好看那么简单。它会污染真实的家庭数据。

我最早想解决的问题是:怎么让 agent 在该调用工具的时候,真的调用正确的工具?

后来做着做着,我发现这句话只说对了一半。工具路由当然要做,但真正的问题不是“模型能不能更聪明一点”。真正的问题是:一个 agent 应用必须有一层可靠的工程外壳,把模型的语言理解和产品里的决策分开。

也就是大家所谓的 Agent Harness Engineering

模型可以判断“这句话像是在记录配方奶,数量是 91ml”。但后端必须决定:当前用户有没有权限、字段是否完整、是否需要确认、应该调用哪个 service、结果怎么返回、这次操作如何测试。

这篇文章就是一次从工具路由走到短期记忆的工程复盘。

模型负责理解语言。
Harness 负责产品决策。

第一版问题:工具调用不能靠模型自觉

Fawn agent MVP 是 LangGraph ReAct 风格。模型拿到用户消息后,可以选择调用工具。工具列表里有记录类、查询类、宝宝档案、相册等能力,例如:

  • record_feeding
  • record_sleep
  • record_growth
  • record_health
  • update_tracker_record
  • delete_tracker_record
  • query_feeding_data
  • query_sleep_data
  • get_baby_profile
  • browse_photos

这种模式很适合开放式聊天。用户问一个宽泛问题,模型可以自己规划下一步,必要时查资料、读档案、调用工具。

但结构化业务不是这么回事。

还是那句“刚刚喝了91ml配方奶”。它在产品里对应的流程大概是:

  1. 判断这是喂养记录。
  2. 提取喂养类型:配方奶。
  3. 提取数量:91ml。
  4. 把“刚刚”锚定到当前时间。
  5. 检查当前用户有没有写 tracker 的权限。
  6. 调用 tracker service 写入。
  7. 通过 SSE 把工具调用和结果返回给前端。
  8. 保存最终 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 小时过期。

这个规则当然不完美。一个家庭里可能同时有人在补睡眠记录,有人在改喂养记录。但在当前产品里,“一个家庭共享一个聊天窗口”才是主要形态。先把最常见的链路做稳,比一开始设计一个复杂的多任务系统更重要。

任务状态可以很朴素:

  • pending
  • awaiting_confirmation
  • completed
  • cancelled
  • expired

任务上还可以记录谁发起、谁最后补充、谁确认。这样它就不是一段 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: