作者构建工业机器记忆agent时,误将Recall当Reflect使用,导致LLM承担了本不该承担的推理工作,揭示了memory系统设计细节。
我的 README 说这个 Agent 使用了 Hindsight 的 Reflect 操作来对机器的故障历史进行推理。我的代码调用的是 Recall,把结果塞进 prompt,然后让 LLM 自己做推理。没有人发现这个问题,直到我去找一个完全不相干的 bug。
想法很简单,而且我觉得用得还不够多:每台工业机器都应该有自己的记忆。一个技师今天修了一台泵;六个月后,另一个技师在同一台泵上看到相同的振动模式,却完全不知道以前也发生过。维修报告在某个文件夹里,或者在第一个技师的脑子里,两者在凌晨 2 点生产线停机时都无从查找。
所以我构建了一个小系统——一份合成维护记录的 JSON 文件、一个 Flask UI,以及一个做记忆工作的 Hindsight——让技师描述他们看到的情况,然后返回一个基于上次实际情况的答案,包括那些没有起作用的修复方案。不是"这是一份通用故障排除指南",而是"R. Bhatt 曾在 2 月尝试给这个轴承重新加脂,没用,以下才是真正有效的方案"。
架构刻意保持精简:
Technician input (symptoms / sensor readings)
│
▼
demo_agent.py
│
┌───────┴───────┐
│ │
RECALL REFLECT
│ │
└───────┬───────┘
▼
Hindsight Memory
│
▼
Groq LLM
两种模式,两种不同的工作:并排的"有记忆 vs 无记忆"对比用 Recall——抓取相关片段,交给 LLM,让它推理。维修前检查用 Reflect,它应该自己做推理,直接以记忆为基础。这两者的区别就是这篇文章要讲的核心故事。
我在把这个东西当成完成品之前做了一次诚实的自我审查,问了自己一个简单的问题:代码里到底有没有调用过 reflect()?我用 grep 搜了。没有。
这就是 check_before_repair 长这样:
def check_before_repair(symptoms, lang="English"):
...
texts, err = recall(symptoms, machine)
...
a = llm("You are the Chief Reliability Engineer for a plant. " + SCHEMA, user, json_mode=True)
Recall,然后一个通用的聊天补全在做实际的判断。演示起来效果很好。但这也完全不是我对任何人说的那样。差异比听起来重要得多:Recall 的工作是检索——返回相关片段,然后退出。Reflect 的工作是综合——判断这是否匹配一个已知的故障模式,权衡相互冲突的证据,然后回答真正的判断问题。我用手工做了第二件事,用一个手写的 prompt,而不是让记忆系统来做这件事。
修复方法是直接调用 Hindsight 的 reflect(),并使用结构化输出:
def reflect(query, context=None, tags=None, response_schema=None, budget="mid"):
r, e = call_with_retry(lambda: hs().reflect(
bank_id=BANK_ID, query=query, context=context, tags=tags,
tags_match="any", budget=budget, max_tokens=1600,
response_schema=response_schema, include_facts=True))
if e: return None, friendly_error(e)
return r, None
response_schema 给我一个 JSON Schema,Hindsight 返回匹配它的 structured_output——不再需要让 LLM "请只返回有效 JSON"然后祈祷。include_facts=True 是真正改变架构的部分:它返回一个 based_on 字段,列出 Hindsight 用来生成答案的确切记忆、心理模型和指令。我 UI 中的"为什么 Agent 认为这个"面板现在渲染的就是这个——不是一张我召回并希望模型使用的列表,而是一张模型自己报告它实际使用了什么的列表。
Reflect 接好之后,我的引用检查代码坏了。我曾经建了一个守卫,只允许模型引用一个记录 ID——如果那个 ID 出现在检索文本的某处。一开始它拒绝了所有引用。
原因是 Hindsight 的事实提取会把内容改写成原子语句,而我保留文本里嵌入的 [MAINT-2025-001] 风格的标签没能存活于那个改写过程。但我在 retain 时同时传入的 context 字符串——"maintenance record MAINT-2025-001 | machine MCH-017 | tags: ..."——在每条事实上都原封不动地返回了:
id_blob = texts + [f.context for f in facts if f.context]
conf = confidence(symptoms, id_blob)
srcs = sources_from(id_blob)
教训,用一行说清楚:如果你需要某样东西在 Hindsight 的提取过程中原样存活,把它放进 context,别放进你要让它总结的 content 里。
另一个真正的 bug:一个缓存的 Hindsight 客户端在跨请求复用时,偶尔会抛出 Timeout context manager should be used inside a task——这是典型的 aiohttp session 在一个 asyncio 事件循环里创建却被另一个复用的症状。Flask 的开发服务器每个请求起一个新线程;同步封装每次调用起一个新的事件循环。在两者之间缓存客户端,最终必然撞车。
一旦理解了问题,修复就便宜了:停止缓存,每次调用构建一个新的客户端,对任何看起来像是瞬时错误的东西重试一次:
def hs():
from hindsight_client import Hindsight
return Hindsight(base_url=os.environ["HINDSIGHT_BASE_URL"],
api_key=os.environ["HINDSIGHT_API_KEY"])
def call_with_retry(fn, attempts=2, delay=1.2):
for i in range(attempts):
try:
return fn(), None
except Exception as e:
if i < attempts - 1 and _is_transient(e):
time.sleep(delay); continue
return None, e
我之所以发现这个,纯粹是因为我故意测试了并发请求。否则它只会在实际用户面前暴露,而我最不希望它在那里出现。
让我确信这不只是"带额外步骤的检索"的部分,是那个闭环。记录一个失败的修复方案,同一台机器的下一个诊断就会改变——不是因为我又跑了一遍脚本,而是因为记忆本身增长了:
记录反馈之前:
"将润滑周期从每月一次延长到每季度一次失败了两次,一次是因为手动策略变更,一次是因为 CMMS 软件重置。"
记录"重新给轴承座加脂——没用"之后:
"重新给轴承座加脂已尝试过并导致失败,见日志 LIVE-001。停止所有重新加脂的尝试;直接更换驱动端轴承为 SKF 6309。"
同一台机器,同一个根本问题,一个真正不同的答案——因为几秒钟前一个技师的结果变成了一条记忆。我还给每台机器配了一个"数字孪生"——一个 Hindsight 心理模型,它的 source_query 让它把那台机器的完整历史综合成一份活的档案,加上 trigger={"refresh_after_consolidation": True} 这样它在新的事故进来时会自我更新,而不是我需要手动重新生成的一份总结。我还把几条常驻规则——接触旋转设备前要先上锁牌、不编造技师名字或日期——从我自己的 prompt 文本里移出来,放进了 Hindsight 指令里,这样它们就由记忆系统在每个请求上强制执行,而不是靠我控制的那个字符串里的约定。
Grep 你自己的声明。如果你的文档说你用了某个操作,要验证代码路径真的调用了它。我没有,持续了好几周。
想清楚什么需要原样存活,然后把它放在不会被总结的地方。context 不是 content;要刻意区别对待。
在用户之前测试并发。我发现的最可怕的 bug 只在同时请求时才存在,而我只是通过故意制造同时请求才发现的。
一个系统的记忆应该在你不动代码的情况下变得越来越聪明。如果你的 Agent"学习"的唯一方式是编辑一个种子文件,那它就不是在学习——而是在重新创作。
检索和推理是不同的工作。Recall 用于"什么相关"。Reflect 用于"这意味着什么"。混淆它们是个容易踩的坑,而我第一口就掉进去了。
这一切都不需要更大的模型。它需要的是记忆系统真正在做我告诉所有人的它在做的那个工作。