Field Notes  /  Agent Infrastructure
GitHub

从 POC 到生产的裂缝

Wired but Dark

如今人人都在做 stateful agents。可有状态是读路径的属性 —— 不是存储的属性。两套 harness 通过了每一次 demo,却在生产环境里悄悄退化成了无状态。

复盘 · 自建 harness 审计 2026 年 8 月 阅读约 8 分钟

Agent 系统里最贵的 bug 从来不是崩溃的那种,而是那些返回一个看似合理的值的 —— 一个空列表、一段格式完好的字符串 —— 让下游一切照常运行,仿佛系统真的在工作。harness 通过了 demo、上了线,只在几个月后的真实流量里才暴露出窟窿:表现为"这 agent 好像没有记忆",或者"工具能用,除了它不能用的时候"。

我审计过一套生产环境的 harness,它用一个 14B 参数的开源模型驱动一队专职子 agent。纸面上它什么都有:持久化上下文、工具层、supervisor 路由。但其中两根支柱是空心的 —— 不是缺失,是空心。围绕它们的脚手架足够完整,完整到没人察觉。下面把这两个都还原成通用的模式讲一遍 —— 因为如果你也围着一个小模型搭过 harness,你大概率至少踩中过其中一个。

TRAP 01

接好线,却不通电 存得完美、读回来却什么都没有的记忆

这套 harness 有一个货真价实的持久化层。每一轮都把上下文写进存储 —— append 语义、版本冲突重试、身份校验、双写镜像一应俱全。一个 get_context_service 被注入到子 agent 的 十二 个调用点。按任何结构性指标衡量,记忆都是接好线的。

然后是读路径:get_context_messages()。那段把存储记录还原成模型消息的函数体,在过去某次重构里被注释掉了,替换成一句光秃秃的 return []。它唯一的调用方 —— 主轮次循环 —— 尽职地在每个请求开头拉取历史,拿回一个空列表。README 承诺"凭 contextId 恢复任意对话"。代码忠实地写下了那段历史,却一个字都没读回来过。

第 N 轮 · 写入 save_context() 第 N+1 轮 · 读取 get_context_messages() 上下文存储 已持久化 · 健康 写入上下文 ✓ 返回 [ ] 变换函数被注释掉
不对称本身就是这个 bug。写入落盘、存储被填满 —— 记录就在那儿。读回来是空的,不是因为存储空了,而是因为那唯一一个把记录转成消息的函数被打了桩。一个检查"存了吗?"的测试会通过。只有"第 2 轮看得见第 1 轮吗?"才抓得到它。

它为什么藏得这么深:每一个可观测的信号都是绿的。存储在填满。save 调用成功。十二个注入点都能解析。看板显示上下文已写入。那唯一被切断的一跳,是个没有副作用可供监控的纯函数变换 —— 而模型拿到一段空历史,就只是表现得像个无状态聊天机器人,读起来像"小模型本来就健忘",而不是"我们的读路径返回了一个字面意义上的空列表"。

2026 视角 整个生态如今正拿这根支柱当卖点 —— "universal memory layer""stateful agents""durable execution"。但一个只写不读的记忆层,是一份 write-only 日志;而有状态是路径的属性。这不是缺了个功能,这是一场披着绿色看板的 durability failure(持久性失效)。

破绽在这儿

基础设施的广度不等于它的深度。十二个调用点接到一个服务,说明管道铺好了;至于水到底出不出来,它一个字也没说。在你的持久化层和工具层里 grep 一下 return []return NonepassTODO —— 尤其是那些蹲在 README 声称"能用"的函数体里的。

TRAP 02

绕道 text-JSON 用正则从散文里抠出工具调用

现代模型端点都提供原生函数调用:模型吐出一个结构化的 tool_calls 字段,运行时直接从里面读 namearguments,分发毫不含糊。我审计的这套 harness 没用它。它反而提示模型打印一段带围栏的 JSON,再用 re.search(r'```json ... ```') 在原始文本上把调用抠回来 —— 而原生绑定工具只存在于一条生产永远走不到的 mock 路径里。

这在 demo 里能跑,是因为一个 14B 模型在好提示词下通常能把那段格式打对。"通常"正是问题所在。只要模型把 JSON 裹进一句话、吐出两段块、多带一个逗号,或者正则撞上嵌套花括号,解析就会悄无声息地落空 —— 而一次落空的解析,在下游看来和"模型选择了不调工具"一模一样。

2026 视角 到今天,这甚至不再是一道自建还是选型的题。整个生态已经把工具调用标准化成了一套协议 —— MCP,spec 版本 2025-11-25,主流框架悉数采纳 —— 正是为了让没人再去手搓模型↔工具这道边界。从散文里解析 JSON 不是绕过它的捷径,而是主动退出了所有人都已收敛到的那份唯一契约。

model output 工具执行 name + args 原生函数调用 tool_calls{} dispatch 结构化 · 无歧义 提示词-JSON + 正则 ```json … ``` re.search() 脆弱的一跳 未命中 → 调用被静默丢弃
把差别画出来:就差一跳。text-JSON 泳道在模型和分发之间插了一个原生泳道压根没有的正则解析。这条泳道所有的失效模式,都住在那一个多出来的盒子里 —— 而一旦它失败,就朝着"没有工具调用"的方向失败,偏偏那是唯一一个看起来像模型正当决策的结果。

还有第二个、更微妙的理由偏向原生字段 —— 尽管它同样会坏:当它坏的时候,往往坏得很。这就引出了它那位同门 ——

同门失效 · 同一道边界

原生工具调用也不是自动就安全的。在某些推理后端提供的 OpenAI 兼容端点上,流式 tool_calls 的续传 chunk 会把 nameid 显式重复成 null,而参考实现只是省略这些字段。一个写成 if (delta.name !== undefined) 的累加器,会让那个 null 覆盖掉首个 chunk 里抓到的真实名字 —— 于是每一次调用都变成 unknown tool ""。修法是 != null。这正是协议本该杀死的那类失效 —— 可它照样活了下来,因为标准规定的是数据的形状,管不住每个自称说这套协议的后端在 wire 上各自的漂移。和 Trap 02 是同一课,只是往下沉了一层:哪怕走原生路径,你和模型之间的这道边界,仍然是各家 provider 约定发生分歧、"通常没事"的解析去送命的地方。

为什么 2026 让这一切更锋利

有状态,是读路径的属性。

Agent 的话题已经往前走了。卖点不再是"它能不能调工具",而是 stateful agentsdurable execution、一个 universal memory layer。而这恰恰是这两个陷阱更重要、而非更不重要的原因:它们正是一套 harness 宣称拥有的属性,和它实际拥有的属性之间的那道缝。三个失效在 demo 里全都隐形,只有在 trajectory(轨迹)里才现形。

  • Trap 01 是一次 durability failure。只写不读的存储,是一份 write-only 日志。如果你的系统是以"有状态"卖出去的,那么一条失忆的读路径就不是少了个功能 —— 而是那句头号承诺,正在无声地失效。
  • Trap 02 是主动退出标准。生态已经就模型↔工具这道边界收敛到了一套协议(MCP,版本 2025-11-25),好让没人再去手搓它。2026 年还从散文里正则抠工具调用,等于选了整个领域已一致同意抛弃的那条路。
  • 同门 bug 是 wire 层的 spec 漂移。哪怕是原生工具调用,标准化的也只是数据的形状。在参考实现省略字段的地方塞一个显式 null,照样能溜过去 —— 契约救不了一个把它说得很随意的后端。
DEMO 视角 — 信号全绿 prompt model reply ✓ ✓ ✓ TRAJECTORY 视角 — 两个暗跳步 读取记忆 [] model parse ✗ 工具没跑 又盲 · 又聋
同一次运行,两台仪器。表层信号一路绿灯,而读路径返回 []、工具调用的解析静默落空。只有 trajectory —— 这个领域如今用来评测的单位 —— 才显出那两个暗掉的跳步。

为什么两个都能活到生产

一次 demo,只把 happy path 走了整整一遍。

两个陷阱共享同一种签名:一个结构上在场、功能上空心的组件,朝着一个看起来合法的值失效。空历史读起来像健忘。丢掉的工具调用读起来像模型的决定。两者都不抛异常,都不 page 任何人。而一次 demo —— 一个干净的轮次、一个格式良好的调用 —— 永远不会落在那个能暴露窟窿的输入上。"接好线"和"能工作"之间的距离,恰好就是你 demo 没试过的那批输入。

如果你不想自己手搓

小模型 harness 与 memory 库

当一套自建 harness 一次次滑进这些陷阱,一个有人维护的库 —— 或它底下的那套协议 —— 通常早已替你把边界处理的账付清了。下面是针对 7B–14B + 工具调用 + memory 技术栈的粗略定位。星数截至 2026 年 8 月;有几个一度处在中心的项目已明显停更,而有一份契约已成了其他所有人构建之上的默认底座。

项目Stars是什么记忆模型什么时候选它
MCP 事实标准 89.8k 不是 harness —— 而是模型↔工具这道边界的协议(spec 版本 2025-11-25)。servers 仓 89.8k,Python SDK 24.1k。 不适用(是传输层,不是记忆) 改用这个 Trap 02 的直接答案。说这套契约,别再解析散文。
LangGraph 40.2k 面向 agent 控制流的图运行时;create_react_agent 可跑在任意 OpenAI 兼容端点上。 Checkpointer(短期)+ store(长期,Postgres 支撑)。 生产级 控制流与状态边界最扎实;支持 durable execution。用于正经部署。
Qwen-Agent 停更约 6 个月 17.0k 阿里的 harness,自带 Qwen 原生函数调用模板;对接 vLLM。 历史累积 + 自动文件 RAG。 上手最快 你的模型栈就是 Qwen。胶水代码最少 —— 前提是你能接受它慢吞吞的发版节奏。
Letta 前 MemGPT 24.3k 围绕可自我编辑的分层记忆构建的 agent server。 core / archival / recall 三层,跨会话自动持久化。 记忆优先 长期记忆是硬需求,而不是一个附加功能。
smolagents 28.9k 约千行的极简 harness;CodeAgent 把动作直接表达成代码。 轻量 / 自备实现。 原型 做实验、本地跑 transformers/Ollama、教学。
mem0 63.8k 是一个记忆,不是 harness —— 把长期记忆栓到任意运行时上。 这就是它的全部工作;本地 embedding 可配。 组件 你想保留自己的 harness,但 Trap 01 一直咬你。
gorilla / BFCL 停更约 4 个月 13.0k 面向函数调用的训练 + 评测,不是运行时。 不适用 评测 用来挑哪个小模型工具调用调得好(BFCL 榜)。
AutoGen 停更约 4 个月 60.6k 多 agent 框架 —— 现已进入维护模式。 对话历史。 迁移风险 官方口径已指向继任项目。新项目:掂量一下。
Open Interpreter 68.1k 已重写成一个 Rust 编码 CLI agent;不再是通用 harness。 会话内作用域。 已转型 你要的是那个 CLI 工具,而不是拿来搭东西的库。
Pydantic AI 较新 19.4k 工具调用端到端按类型化 schema 校验;一次解析要么符合、要么直接抛错。 消息历史;可插拔。 类型安全 如果 Trap 02 吓到你了:schema 就是契约,在边界处当场校验。

在你宣布"上线了"之前

四个能抓住空心支柱的冒烟测试

  • 第 2 轮看得见第 1 轮一个集成测试 —— 不是 mock —— 对着真实存储跑两轮,断言第二轮拿到的历史非空。就这一个测试,第一天就能抓住 Trap 01。
  • 把解析出的工具名打进日志,空了就告警为每一次调用输出解析出的 name,当它为空或未知时告警。一次被丢弃或被抹空的调用应该 page 人,而不是混进"模型什么都没调"里。
  • 拿 README 和代码对一遍 diff文档声称能用的每一项能力,都指出交付它的那个函数。在这些层里 grep return []return NonepassTODO —— 尤其在那些本该干活的函数体里。
  • 测 wire,别测 SDK 的 happy path从你真实的端点抓下真实的流式 chunk —— 显式 null、被拆开的 arguments、被裹起来的块 —— 再把它们回放给你的解析器。各家 provider 的约定,恰恰在你的单测假设它们不会分歧的地方分歧。