上一篇:不会还有人把聊天记录当组织记忆吧?噪声和密钥一起进库更可怕 | 蒸馏黑名单
不会还有人失败了就靠群里说过吧?没有审核的自动注入是事故生产线 | 捕获到召回
系列:《从 Rule 到落地:AI 工程化实践》第 19 篇
标签:记忆层 · 闭环 · 审核 · 召回
专栏:从小白到AI落地大师
序言
蒸馏原则有了,童鞋们要的是流水线。本篇把记忆做成闭环:捕获 → 记录 → 审核 → 注入 → 召回。每个环节有输入输出,人工审核点钉死,召回策略克制。说句扎心的:没有审核的自动注入,不是效率,是事故生产线——错误经验被正式盖章,比遗忘毒一百倍。
说句自嘲的:我上过「全自动记忆」的当,一周末注入两百条草稿,其中一半是基础设施超时。后来召回天天推荐「再等一等」,排障像算命。
记住一句话:召回 = 安检 + 排队 + 限量。安检(过滤)决定谁有资格进候选,排队(打分)决定谁站前面,限量(三重刹车)决定书包里装几条。三步缺任何一步,档案室都会变成倾倒场——要么垃圾进 Context,要么真教训被挤出去。
概念
捕获,从失败、会话结束或手动入口收集原料。审核,去敏、可复现性、是否值得组织记住。注入,写入 lessons 库或生成 Rule、Skill 变更。召回,按症状与标签检索并置入当前 Context。Audit,对注入与调用过程的可追溯记录。
可以理解为:原料进厂、质检、入库、出库领用。质检关掉的工厂,产量越高,中毒越快。
整条闭环的状态机画出来是这样(含隔离与退役两条容易被忘的岔路):
注意pending到approved这条边上只写了「人审通过」——状态机里不存在任何从 pending 直达 injected 的捷径。自动注入的口子一旦留了,状态机就白画了。
问题在哪
闭环断在哪,病就在哪,对号入座四种:
- 只捕获不审核:垃圾入库。半夜的第三方超时、未脱敏的主机名,全都变成「组织智慧」。
- 只注入不召回:档案室积灰。条目写得漂漂亮亮,会话开始时没人去取。
- 召回不刹车:一次塞十条进 Context,旧教训把当下要用的 Rule 挤出去——另一种 Context 炸裂。
- 用「群里说过」代替注入:说过不等于可检索,更不等于过审。三个月后新人翻群记录考古,那不是记忆,是遗迹。
举例:一个完整可跑的召回闭环
第 18 篇给了 lesson 长什么样,这篇我们把「召回」这一步真正跑起来。案例是虚构 memory-boot 的摘要版:一百多行 Python,只依赖标准库,构造 10 条仿真 lesson、1 个会话上下文,把过滤、打分、三重刹车、晋升触发全部跑给你看。
步骤总览,大家先喽一眼:
- 步骤 1:定义 Lesson 数据结构,构造 10 条「贴近真实麻烦度」的仿真条目——2 条该被过滤的、5 条分数拉开梯度的、3 条不相关的;
- 步骤 2:打分函数(标签 + 症状 + 新鲜度)与召回主函数(过滤留痕、按分排队、三重刹车);
- 步骤 3:晋升检查——召回计数到 3,自动触发候选 Skill 评审;
- 最后:真实运行输出,逐行拆给你看每道刹车各砍了谁。
步骤 1:数据结构与仿真数据
# memory_boot_demo.py —— 第19篇完整案例(虚构 memory-boot,仅依赖标准库)fromdataclassesimportdataclassfromdatetimeimportdate,timedeltafrommathimportexp@dataclassclassLesson:id:strtitle:strtags:listsymptoms:liststatus:str# pending / injected / retiredhit_count:intlast_hit:date review_by:date tokens:int@propertydefexpired(self):returndate.today()>self.review_by@dataclassclassCtx:"""当前会话的召回上下文:从报错/任务描述里提取的标签与症状"""tags:listsymptoms:listTODAY=date(2026,10,13)defmake_lessons():return[Lesson("lesson-2026-001","[parse-escape] 对比输出偶发 unexpected token",tags=["adaptor","parsing"],symptoms=["unexpected token","escape"],status="injected",hit_count=2,last_hit=TODAY-timedelta(days=3),review_by=TODAY+timedelta(days=49),tokens=320),Lesson("lesson-2026-007","[parse-escape] Unicode 空白变种(U+00A0)",tags=["adaptor","parsing"],symptoms=["unexpected token"],status="injected",hit_count=1,last_hit=TODAY-timedelta(days=10),review_by=TODAY+timedelta(days=80),tokens=280),Lesson("lesson-2026-009","[regression] 逃逸字符混进列宽断言",tags=["adaptor","e2e"],symptoms=["escape"],status="injected",hit_count=1,last_hit=TODAY-timedelta(days=7),review_by=TODAY+timedelta(days=40),tokens=250),Lesson("lesson-2026-008","[fmt] 末尾空白导致 diff 误报",tags=["adaptor"],symptoms=["escape"],status="injected",hit_count=1,last_hit=TODAY-timedelta(days=2),review_by=TODAY+timedelta(days=70),tokens=180),Lesson("lesson-2026-010","[parse-tail] 行尾空白让断言偶发红",tags=["adaptor","parsing"],symptoms=[],status="injected",hit_count=0,last_hit=TODAY-timedelta(days=12),review_by=TODAY+timedelta(days=60),tokens=150),Lesson("lesson-2026-002","[fixture-drift] 本地绿 CI 红",tags=["test","fixture"],symptoms=["green in local","red in ci"],status="injected",hit_count=1,last_hit=TODAY-timedelta(days=15),review_by=TODAY+timedelta(days=20),tokens=260),Lesson("lesson-2026-003","[db-conn] 连接池超时",tags=["db","ops"],symptoms=["connection timeout"],status="injected",hit_count=0,last_hit=TODAY-timedelta(days=120),review_by=TODAY+timedelta(days=60),tokens=300),Lesson("lesson-2026-004","[old-api] 旧版导出格式漂移",tags=["adaptor","export"],symptoms=["column mismatch"],status="injected",hit_count=0,last_hit=TODAY-timedelta(days=200),review_by=TODAY-timedelta(days=5),# 已过期tokens=290),Lesson("lesson-2026-005","[draft] 疑似网关超时",tags=["ops","gateway"],symptoms=["504"],status="pending",hit_count=0,last_hit=TODAY-timedelta(days=1),review_by=TODAY+timedelta(days=30),tokens=210),Lesson("lesson-2026-006","[ui-theme] 深色模式对比度",tags=["ui","css"],symptoms=["contrast warning"],status="injected",hit_count=0,last_hit=TODAY-timedelta(days=6),review_by=TODAY+timedelta(days=90),tokens=180),]为什么造 10 条而不是意思一下来 2 条?因为刹车的价值只在候选够多时才看得见——条目一少,top_k 和预算永远触发不了,你测的就不是召回,是摆拍。「贴近真实麻烦度」体现在三处:
- 001 和 007 是同一个坑的主条目与变种(第 20 篇细说怎么演进);
- 004 已过复核期、005 还躺在 pending——它们必须被过滤,而不是被打个低分继续参与排名。安检不过的人不参加排队;
- 五条相关条目的分数刻意拉开梯度(0.81 / 0.64 / 0.63 / 0.50 / 0.47),让三道刹车在同一个输出里各砍一刀,看得明明白白。
步骤 2:打分与三重刹车
W_TAG,W_SYM,W_DECAY=0.5,0.3,0.2HALF_LIFE_DAYS=30FLOOR,TOP_K,BUDGET=0.4,3,800defdecay_of(lesson):age=(TODAY-lesson.last_hit).daysreturnexp(-age/HALF_LIFE_DAYS)defscore(lesson,ctx):tag_hit=len(set(lesson.tags)&set(ctx.tags))/len(ctx.tags)sym_hit=len(set(lesson.symptoms)&set(ctx.symptoms))/len(ctx.symptoms)returnW_TAG*tag_hit+W_SYM*sym_hit+W_DECAY*decay_of(lesson)defrecall(lessons,ctx):trace,out,used=[],[],0cands=[lforlinlessonsifl.status=="injected"andnotl.expired]forlinlessons:# 过滤过程全留痕ifl.status!="injected":trace.append(f"[过滤]{l.id}status={l.status},不进候选")elifl.expired:trace.append(f"[过滤]{l.id}已过复核期,不进候选")ranked=sorted(((score(l,ctx),l)forlincands),reverse=True)fors,linranked:ifs<FLOOR:trace.append(f"[打分]{l.id}{s:.2f}< floor{FLOOR},砍")eliflen(out)>=TOP_K:trace.append(f"[打分]{l.id}{s:.2f}已满 top_k{TOP_K},砍")elifused+l.tokens>BUDGET:trace.append(f"[预算]{l.id}{s:.2f}装不下 "f"({used}+{l.tokens}>{BUDGET}),砍")else:trace.append(f"[打分]{l.id}{s:.2f}召回({l.title[:22]}…)")out.append((s,l));used+=l.tokensreturnout,used,trace三个设计决定,说下为什么:
权重 0.5 / 0.3 / 0.2,标签占大头。标签是人工审核时亲手钉的,是最可信的信号;症状只是报错原文的关键词,噪声多,做参考就好。你当然可以调,但别让症状权重盖过标签——那是让机器猜压过人审。
新鲜度用指数衰减,30 天半衰。30 天没命中的教训权重自动减半,90 天只剩八分之一。写死在公式里,就不用再定一条「多久算老」的规则去扯皮。
三重刹车的检查顺序:floor → top_k → budget。先看分数够不够资格,再看名额满没满,最后看书包装不装得下。注意recall里每砍一刀、每放一条,都往trace里写了一行——召回过程必须可审计。哪天有人问「为什么上次没提醒我这条」,答案就在 trace 里,而不是靠当事人回忆。
步骤 3:晋升检查
PROMOTE_THRESHOLD=3defcheck_promotion(recalled):fors,linrecalled:l.hit_count+=1# 召回即计一次命中l.last_hit=TODAYifl.hit_count>=PROMOTE_THRESHOLD:print(f"[晋升]{l.id}命中{l.hit_count}次,进入候选 Skill 评审")defmain():ctx=Ctx(tags=["adaptor","parsing","e2e"],symptoms=["unexpected token","escape"])print(f"会话上下文 tags={ctx.tags}symptoms={ctx.symptoms}")print(f"召回参数 floor={FLOOR}top_k={TOP_K}budget={BUDGET}tokens\n")lessons=make_lessons()out,used,trace=recall(lessons,ctx)forlineintrace:print(line)print(f"\n[结果] 召回{len(out)}条,占用{used}/{BUDGET}tokens")fors,linout:tag=len(set(l.tags)&set(ctx.tags))/len(ctx.tags)sym=len(set(l.symptoms)&set(ctx.symptoms))/len(ctx.symptoms)print(f"{l.id}总分{s:.2f}= 0.5*{tag:.2f}(标签) "f"+ 0.3*{sym:.2f}(症状) + 0.2*{decay_of(l):.2f}(新鲜度)")print()check_promotion(out)if__name__=="__main__":main()第 18 篇说过的晋升阈值「同类命中 3 次」,落点就在check_promotion:001 条目构造时 hit_count=2,本次召回再计 1 次变成 3,自动触发候选 Skill 评审。晋升不是有人想起来才做,是召回计数自然到线——这就把「记忆不是终点」从口号变成了代码里的一个 if。
跑起来长这样
python memory_boot_demo.py,真实输出原样贴上:
会话上下文 tags=['adaptor', 'parsing', 'e2e'] symptoms=['unexpected token', 'escape'] 召回参数 floor=0.4 top_k=3 budget=800 tokens [过滤] lesson-2026-004 已过复核期,不进候选 [过滤] lesson-2026-005 status=pending,不进候选 [打分] lesson-2026-001 0.81 召回([parse-escape] 对比输出偶发 …) [打分] lesson-2026-009 0.64 召回([regression] 逃逸字符混进列宽断…) [预算] lesson-2026-007 0.63 装不下 (570+280>800),砍 [打分] lesson-2026-008 0.50 召回([fmt] 末尾空白导致 diff 误报…) [打分] lesson-2026-010 0.47 已满 top_k 3,砍 [打分] lesson-2026-006 0.16 < floor 0.4,砍 [打分] lesson-2026-002 0.12 < floor 0.4,砍 [打分] lesson-2026-003 0.00 < floor 0.4,砍 [结果] 召回 3 条,占用 750/800 tokens lesson-2026-001 总分 0.81 = 0.5*0.67(标签) + 0.3*1.00(症状) + 0.2*0.90(新鲜度) lesson-2026-009 总分 0.64 = 0.5*0.67(标签) + 0.3*0.50(症状) + 0.2*0.79(新鲜度) lesson-2026-008 总分 0.50 = 0.5*0.33(标签) + 0.3*0.50(症状) + 0.2*0.94(新鲜度) [晋升] lesson-2026-001 命中 3 次,进入候选 Skill 评审输出你看什么
逐行拆,这段输出信息密度不低:
- 头两行 [过滤]:004 过期、005 pending,注意它们根本没进打分队列。过期条目就算和当下症状一字不差,也无资格参选——安检不过的人不排队。这就是「过滤」和「打低分」的本质区别。
- 001 的 0.81 怎么来的:结果区给了演算——0.5×0.67(标签命中 adaptor、parsing 共 2/3)+ 0.3×1.00(两个症状全中)+ 0.2×0.90(3 天前刚命中,新鲜度还热)。三个因子各有贡献,谁也别想吃独食。
- 最戏剧的一刀在 007:它拿了 0.63 的高分,比最终入选的 008(0.50)高出一截,却被 [预算] 砍了——前面已装 570 tokens,再加它的 280 就超 800;而 008 只要 180 tokens,书包装得下,挤了进来。预算刹车不是按名次切尾,是装得下才装。教训也讲性价比,这不是段子,是召回的真实行为。
- 010 的 0.47 其实够 floor(0.4),但名额满了,top_k 一刀。至此三道刹车在同一批候选里各自亮相:floor 砍了不相关的,top_k 砍了来晚的,budget 砍了太胖的。
- 002/003/006 的 0.12/0.00/0.16:连接池超时再重要,深色模式对比度再紧急,都和今天这个 parse 报错无关。floor 的存在就是替你挡住「什么都想提醒一下」的多动症。
- 最后的 [晋升]:001 计数到 3,自动进候选 Skill 评审。第 18 篇的阈值、第 20 篇的演进、这篇的触发,三篇在这里接上了。
一句话收拢这次运行:10 条候选,2 条被安检拦下,3 条进了书包,1 条触发晋升。把make_lessons()换成你们真实库的条目,floor / top_k / budget 就是三个旋钮——先跑基线,再看你们的召回是不是也在「装得下才装」。
最小观测字段表(贯穿编排 / MCP / 测试 / lesson)
召回写 trace 的同时,别忘了和全链路字段对齐,排障时才串得起来:
| 字段 | 含义 | 谁写 |
|---|---|---|
correlation_id | 一次用户意图的全链路 id | 编排入口 |
command/skill | 命中的路由 | 路由层 |
tool | MCP 工具名 | 协议层 |
risk | 风险级 | Manifest / 工具元数据 |
decision | allow / deny / confirm | 门禁 |
test_case_id | YAML / E2E 用例 id | 测试执行器 |
lesson_id | 审核通过的教训 id | 记忆层 |
日志里能靠correlation_id串起「谁点的菜 → 用了哪本手册 → 调了哪个工具 → 哪条测试红了 → 沉淀哪条 lesson」,你才算有黑匣子,而不是只有报警器。
自检
# 闭环自检 - [ ] pending 到 injected 之间是否只有人审一条路? - [ ] 过滤(状态/过期)是否先于打分,且留了 trace? - [ ] 召回是否有三重刹车:floor / top_k / budget? - [ ] 分数构成是否可演算(权重×命中,而非黑盒)? - [ ] 命中计数是否接到晋升阈值? - [ ] 驳回是否有原因模板?两问。第一,pending 能否不经审核变 injected?能,生产线已开工。第二,召回 trace 能不能回答「为什么没提醒我这条」?不能,刹车就还是个传说。
人审工作量经验区间(小团队):每周 5~15 条 lesson 草稿通常健康;长期高于 30,说明上游门禁太松或触发太宽,该去收路由与 Rule,而不是加人加班点审批。低于 1 也不正常——多半是红灯没归档,不是你们从不犯错。
思考
小结三句。没有审核的自动注入是事故生产线。召回三重刹车宁紧勿松——挤掉的是当下 Rule 的注意力。命中计数接晋升,记忆不是终点。
可以理解为:工厂质检开着,出库还有管理员和书包限量,这条流水线才算能开工。下篇实战:把一次失败完整走成可复用 lesson。
下篇预告:《实战:一次失败如何变成可复用 lesson》——变种要演进,不要堆五条重复。
觉得有用的话,欢迎点赞、留言贴一版你家的 floor / top_k / budget 取值;有「召回塞爆 Context」的经历,也欢迎对照三重刹车拆。
专栏:从小白到AI落地大师|《从 Rule 到落地:AI 工程化实践》