派大猩 · Agent 工程笔记

Back

Multi-Agent 编排运行时

views

多 Agent 系统里最贵的 bug 不是「Agent 答错了」,是答错了却查不出它是被谁带偏的。 一个子任务跑歪,主 Agent 拿到一份看起来很正常的结论,把它并进最终答案,交付出去。 回头排查的时候,会话里全是别人的话,谁也说不清那句话最早出自哪一轮、哪一次工具调用。

这套运行时就是冲着这件事做的。它不负责让模型更聪明, 它负责让「谁在跑、跑到哪、结果存哪、断了从哪接」变成能判定的东西,而不是靠主模型的提示词自觉。 模型该决定的是下一步做什么,不该决定有多少个名额、谁能写哪张表、断了以后算不算做完。

多个 Agent 不是目的,拆得开才是

先说清楚什么时候不该上多 Agent。任务拆不开、步骤之间强顺序依赖、 或者子任务做完了没法单独验收,这三种情况下开多个 Agent 是纯亏。 拆一次要付一次账:多一轮模型调用、多一份报告、多一次汇总, 而收益可能只是把一件本来顺序就能做完的事换个地方排队。

值得拆的任务长什么样,看三个特征。第一,子任务之间在等的东西不一样—— 有的在等网页,有的在等数据库,有的在等模型,这些等待能重叠。 第二,每支只需要自己那一段材料,全塞进一个上下文会把它撑爆。 第三,某一支失败时,只补这一支就够了,不必把整条链路重跑。

这三条同时成立,拆分收益才可能盖过协调成本。只成立一条半的时候, 我宁可让主 Agent 自己干完。这个判断没有公式, 但有个很实用的反问:如果这支任务失败了,我会重跑它一个,还是重跑全部? 答案是后者,就说明它不该被拆出来。

再说动态编排和固定工作流的分工。固定工作流适合步骤稳定、输入输出确定的活, 写死了反而省事。但调研类长任务不是这样:资料可能缺、来源可能挂、口径可能打架, 下一步做什么取决于上一步查到了什么。所以后半个流程必须能改。

我的分法是按确定性划线,不按重要性划线。 确定性的东西全部收进程序——并发上限、超时、预算、 哪些列可写、重复执行怎么合并、要不要停止。 不确定的东西留给模型——要不要补查、换哪个来源、这个缺口值不值得再花一轮。 这条线划清楚之后,模型的自由度一点没少,但系统不会因为模型今天心情不好就多开八个进程。

真正难的也不是并行。把几个 Agent 同时点起来,十几行代码的事。 难的是「部分失败」:一支跑挂了、一支卡在超时边缘、一支已经写进去一半、 还有两支在排队没轮到。

这时候系统得回答四个问题——谁还占着名额、谁的活算数、谁缺的那块要不要补、 补的时候会不会把已经做完的再做一遍。 这套运行时的绝大部分代码,都是在回答这四个问题。

还有一笔账要算在前面:多 Agent 不等于省 token。 主 Agent 要收每一份报告、要汇总、可能还要补派,这些都是新增的调用。 省下来的那部分是「每个子任务不必背着全部历史跑」—— 它拿到的只有自己那段材料,交回来的只有结构化结论。 所以上下文总量会降,总调用量通常会升, 这两件事不能混成一个「成本下降」的说法往外讲。

准入只有一个口子,别处不许生 Agent

第一件事是把「生一个子 Agent」收成一个函数。 不管是从主 Agent 派来的批次、从脚本流程派来的补做、还是从恢复入口进来的续跑, 最终都走同一个入口。入口里做三件事:看现在占了多少名额、够不够放这一个、 放就标记成已放行,不够就压进待跑队列。

为什么要收口。因为「为什么这个 Agent 还没跑」必须只有一个答案。 如果判断散在五六个调用点,每个点各写一句 if, 出问题时你连该看哪个分支都不知道。收口之后,这个问题永远指向同一处代码, 排查成本从「读一遍系统」降到「看一个函数」。

计数这一步有个坑,我踩过。新登记的子 Agent 本身就是待启动状态, 如果把它自己算进占用数,那么每一批的第一个都会被自己挤进队列—— 它一进表就把名额占满,然后永远等不到自己被放行。 所以取占用数时必须把待准入的那一个排除掉。

另一个坑在「已放行但还没真正跑起来」这段间隙。 两个结束回调几乎同时到达,都去查当前占用,都看到有名额,于是都放行一个, 并发上限当场被突破一次。 解决办法是把「查名额、取队首、标记已放行」放进同一把锁, 并且已放行未启动的也算占用。第二个回调拿到锁以后重新看最新状态,自然就不放了。

锁只包住上面这三个动作,实际启动在锁外异步进行。 锁里不做模型请求、不做工具调用,所以真实的执行仍然是并发的, 锁保护的是调度决策本身这一瞬间的一致性。

停止也是一道闸门。整批任务被停掉的时候, 那些还没轮到的子 Agent 不能就这么消失——主 Agent 那边还在按批次计数等结果, 等一个永远不会跑起来的东西,整条链路就卡死了。 所以被拒的子 Agent 也要由运行时合成一份终态报告,说明它没跑、为什么没跑。 排队中的那些在停止时统一清理掉。

闸门为什么必须放在准入这一处,而不是放在停止命令里。 因为停止之后,主 Agent 手上可能还有一轮没走完的派发, 它会照着原计划继续往下派。 如果准入不设闸,这些迟到的派发就会在已经清场的地方把 Agent 重新点起来, 停止变成一句空话。放在准入处,任何路径的生 Agent 请求都会被同一个判断拦掉, 没有一个入口能绕过去。

准入还有一层是「能给什么工具」。子 Agent 的工具集是主 Agent 当前工具集的派生子集: 生命周期管理类的工具不给,凭据预检没过的工具不给,当前作用域之外的工具不给。 到了收尾那一轮,只允许调用汇报、写台账、更新任务这三类, 别的工具全部收走。收尾轮的意义是让它把结果交出来,不是让它再开一局。

预算这块我做了一个自适应,但它比看起来要危险。 直觉是:统计正常跑完的任务用了多少次工具调用,取个典型值, 以后就按这个值给预算。问题是被预算打断的任务,它的用量是删失数据—— 它本来可能要用更多,只是被截断了。 把这种样本喂进统计,预算会一轮比一轮紧,最后形成单向棘轮,谁都跑不完。

所以样本准入很严:只收「自己报告成功」且「不是被预算打断」的那些, 零调用的退化样本也丢掉。收进来之后先看离散度, 如果高分位比中位数高出太多,说明这批活根本不是一类活,一个统一预算没有意义,直接不收紧。 收紧看窗口内的中位数,放宽却只认这批任务生命周期里的最大成功值—— 两头不对称,就是为了防止棘轮。

还有一条:收紧不会对正在跑的任务中途生效,它通过在下一轮迭代边界上生效。 绝不允许一次工具调用跑到一半被切断,那样留下的状态半截半全,比多花几次调用贵得多。

收尾和抢资源不是一件事,所以超时分两档

超时如果只有一档,就只能在「继续烧钱」和「直接杀掉」之间二选一。 但这两个动作的目的根本不同:一个是想让它把已有结果交出来,一个是想把资源收回来。 所以这里分成两档。

到了软档,运行时给还活着、还没汇报的子 Agent 注入一条消息, 大意是「时间到了,把手上的结果写进台账,然后汇报,还有一小段宽限期, 过了就什么都没了」。这条消息会在它下一次迭代边界上出现, 变成一个普通的用户轮次,模型看得懂,也知道该做什么。

那段宽限期给多长,有一个实在的依据:够它写完一次台账、再交一次汇报。 给短了,提醒等于没提醒,它连收尾都做不完就被硬停; 给长了,一个卡死的任务会一直占着名额,后面的排队任务全被拖住。 这个长度不是拍出来的,是按「收尾动作需要几次工具调用」估出来的。

已经汇报过的不再催它——它已经交过东西了,再催一次只会让它把同一件事再做一遍。 到了硬档还活着的,强制停止。停止的时候它会走取消路径, 取消路径同样会发终态报告;如果它刚好在停止前汇报了,那份显式报告优先保留。

这里有个真实踩过的坑值得一提。那个守着超时时间的后台任务, 如果只是创建出来而不持有强引用,会在等待期间被回收掉, 软超时的注入就静默丢失了——表现为子 Agent 从来没收到过收尾提醒, 然后被硬停,什么都没交出来。这种 bug 不会报错,只会让结果莫名其妙地残缺。

兜底逻辑比超时本身更重要。正常结束优先用子 Agent 主动提交的报告。 但如果它预算耗尽、软超时到了没汇报、被硬停、被停止、或者抛了异常却没汇报, 运行时就根据已经保存下来的执行结果合成一份终态报告,并标明真实的终止原因。 合成报告不会伪造成功,它只保证主 Agent 知道这件事结束了、做了什么、还缺什么, 从而不会无限等下去。

报告里区分了「部分完成」和「失败」,这个区分值很多钱。 部分完成的意思是已经产出一部分可验收的结果,但有明确缺口; 失败的意思是没拿到能用的产出。二者不能同等处理: 部分完成里可能已经花掉了好几个来源的核验成本、 已经写进去几个字段、已经拿到关键证据,直接重跑等于把这些全扔掉, 还可能重复一遍外部副作用。

主 Agent 收到报告之后的选择有五种,判据不是状态字段本身, 而是报告、台账、验收条件、剩余预算这四个东西合起来看。 临时的网络或限流错误、且没有产生副作用,可以做有限次退避重试; 已经攒下上下文和结果、只是超时或预算耗尽,就恢复它继续跑。

只是缺几个字段,就派一个更小的补充任务; 来源不可访问、口径冲突、方法本身不对,就换个做法重新规划。 权限缺失、超出范围、或者继续下去的成本已经超过价值,就停下并把已知结果和缺口一起交付。

停下不等于失败。交付一份「已知什么、缺什么、为什么缺」的结果, 比交付一份看起来完整却说不清来源的结果有用得多。 长任务里最贵的不是有几条没查到,是没人知道哪几条没查到。

补派这条最容易失控。所以我给它加了两道约束: 每次补派必须绑定到一个具体缺口,并且说清什么叫补完了; 连续几轮没有带来新的有效信息,或者预算见底,就停止探索,把不确定性写进最终交付。 「再查查」不能是默认答案,它必须每次都指向一个具体的、可验证的东西。

子 Agent 之间不许说话,只许交引用

派发的时候,父 Agent 给的是四样东西:目标、输入材料、可用的能力、输出要求。 之后子 Agent 就在自己的上下文里跑,兄弟之间互相看不见, 不能互发消息,也不能互相等待。它要交给别人的东西只有两条路: 共享的业务记录写进台账,执行结果通过结构化报告交回父级。

为什么不共享上下文。共享上下文看起来能力更强—— 每个人都知道别人在干什么,听起来就是「协作」。 实际上它只是把耦合搬到了时间轴上:谁先谁后变成隐式依赖, 一个 Agent 的错误输出会被另一个直接当成前提读走, 而这份依赖在代码里没有任何地方声明过。 传引用笨一点,但每条依赖都是显式的一条边,画得出来,也删得掉。

报告里装的是控制面需要的东西:任务标识、终态、摘要、已完成产出的引用、 缺什么、错误属于哪一类、建议下一步做什么。 详细产物留在它自己的存储里,按引用交接。 主 Agent 拿到的是一份字段明确的表格,不是一段自然语言日志—— 它不需要从「我查了三个网站,其中第二个打不开」这种话里推断任务到底完成没有。

报告里还有一项容易被省掉、但很值钱:它建议父级下一步做什么。 子 Agent 是最接近材料的那一层,它对「这个缺口该怎么补」往往有具体判断。 这个建议不是命令,父级完全可以采纳也可以推翻, 但没有它,父级就只能从错误分类里猜—— 而错误分类是给机器看的,不是给决策看的。

通知走进程内的事件总线,带单调序号和有限历史,能按类型、流、节点过滤。 但我一直把它当通知用,从来不当存储用。事件可以迟到,可以丢, 订阅方的处理也可能抛异常。

所以主 Agent 收到通知之后,仍然会回查台账确认真实进度。先落事实,再发通知—— 这个顺序保证了「写进去了但通知没发出来」这种情况不会丢工作。

反过来也成立:如果一个子 Agent 报告成功,但台账里那条记录缺必填字段, 这条记录就是没完成。执行结束和业务完成是两件事, 把它们分开,是后面所有验收和恢复逻辑能站住的前提。

给前端的推送也做了同一件事的分层。默认只发会话级的元信息, 想看某个子 Agent 的细节得单独订阅那一条流。 否则一个十几支的批次跑起来,整个集群的对话全部推到页面上, 页面先崩,用户看不到任何有用的进度。

慢客户端不能拖住执行。每条连接的队列是有界的, 一个消费不动的连接不会让 Agent 的执行跟着阻塞; 实在跟不上就让连接显式断开,客户端重连时先拿一份当前快照, 再按序号回放有限的一段历史。回放是有限的,这个通道只负责同步状态, 不负责永久保存任何东西。

台账行才算数,进程状态不算

系统里有三种状态,它们的用途完全不同,混在一起就会出事。 运行时状态回答「这个 Agent 在排队、在跑、还是已经结束了」。 业务状态回答「哪些记录做完了、结果在哪、还缺哪些字段」。 执行现场回答「这个 Agent 的上下文和进度存到了哪」。

一个子 Agent 成功结束,而它负责的那条记录缺了必填字段, 这条记录在业务上就是没完成。反过来说,数据已经写进台账、 只是通知没及时送到,也不应该把已经做完的工作丢掉。 这两种情况在只存一种状态的系统里,都会变成没法回答的问题。

台账是一个每个编排实例一份的库。主 Agent 先注册允许写哪些表、哪些列, 子 Agent 通过一个受限接口写:没注册的表不收, 想改表结构也不收。带业务唯一键的行走更新插入语义—— 第一次执行是插入,重跑或恢复的时候是更新同一行,不会产生第二条记录。 需要保留每一次的场合用追加模式,那是另一个语义,不能混着用。

每次行级变更由数据库触发器落进变更表, 这条变更表的序号同时是前端做增量刷新的游标, 不用反复全表扫一遍去找「刚才变了什么」。 触发器和变更表不是装饰品,它们让「这条记录什么时候被谁改成这样」变成可查的事。

安全边界上锁得比较死:会改变库结构、挂载外部库、改写存储引擎参数的语句一律禁掉, 框架自己的内部表不允许通过这条链路去动。写前日志加上忙等待, 多语句的变更走立即事务,避免改到一半被别人插进来。

但有一件事必须说清:唯一键更新保证的是台账内部不重复, 推导不出外部系统也只执行一次。 典型场景是先调外部接口建一张工单、再把工单号存回来—— 本地的更新插入挡不住外部那张工单被建两次。 这类操作要么带幂等键、要么先查状态再决定要不要重发, 不能拿一个台账的更新语义概括整条链路的可靠性。

冲突也是同理。事务能保证一次写入是完整的, 但它判断不了两个相反结论哪个对。 所以我没在写入侧做自动裁决:同一对象出现语义冲突,就留给来源核验, 「数据库没报错」不等于「这些 Agent 已经达成一致」。

最终收口看两样:主 Agent 的验收判定,加台账的必填字段校验。 两样都过,这批才算完成。批量侧另外还有一层按比例熔断的保护, 但它保护的是成本,不是正确性。

一致性上我没有追求强一致,这里是个明确的取舍。 写台账、生成报告、保存检查点、发通知,这四步不在一个事务里—— 硬凑成一个事务,代价是任何一步卡住整条链路就停摆。

我选择的补偿方式是:主 Agent 验收时主动查台账,而不是等通知。 通知是尽力而为的,台账才是能查的东西。 通知丢了,验收照样能发现哪些记录已经做完、哪些还空着。

跑了一半才是常态,恢复是主路不是备胎

长任务里出问题的位置,九成不在「没跑起来」,而在「跑了一半」。 所以恢复不是兜底,它是主流程的一部分。

恢复的到底是什么,要先讲清楚:恢复的是应用层持久化下来的执行状态, 不是把任意一条指令冻结在原地再复活。 具体说,恢复的是会话消息、执行游标里的位置信息、 累计用了多少次工具调用、有没有待处理的输入、有没有被中止过的标记。 局部变量、网络连接、跑到一半的外部请求,一个都不会回来。

恢复之前要过三道检查:有没有别的执行者正占着这个任务、 存储是不是完整的、有没有外部请求处于「结果未知」。 三道都过了才重新走一遍准入——恢复不是特权通道,它同样要占名额。

累计预算只允许提高,不允许清零。这条是硬规矩。 否则反复恢复就成了一个绕过预算的口子: 跑不动了就恢复一次,预算归零,接着跑,永远跑不完也永远不触发收尾。

进程级复活有次数上限,更关键的是有一张不复活名单。 凭据错误、鉴权失败、权限被拒、配置错误、模块缺失、 节点停滞、空流、超迭代——这些命中了就直接不复活。 因为这类错误重试只会拿同一份坏配置反复起进程, 除了把日志撑爆以外没有任何收益。 判断「这个错误重试有没有意义」比「重试几次」重要得多。

恢复的粒度也有两种,用错地方就浪费。 恢复一个 Agent,关心的是「它原来的执行接着往下走」: 它读过什么、用户补过什么约束、已经用掉多少预算,都得留着。

恢复一批任务,关心的是「还有什么业务活没干完」: 可以换一批全新的 Agent,照着台账把剩下的行做掉就行。 一百条里八十条已经合格,批量恢复应该跳过这八十条; 其中有一条中途需要用户补条件,那一条更适合把原 Agent 叫醒接着干。

还有一种情况必须承认边界:服务重启之后, 我能看到持久化的任务记录,但如果一个外部请求已经发出去、 结果还没来得及落盘,本地状态告诉我「没发生」是不可信的。 查询型的操作重查一次无所谓,有副作用的操作得先查外部状态或者依赖幂等键。 检查点是在迭代边界和关键等待点写的,不是每条外部副作用之后都写一次事务—— 这个差距就是「恢复」和「只执行一次」之间的距离,不能混着说。

恢复逻辑对不对,不能靠真跑大模型来验证,那太慢也不稳定。 我的做法是用可控的执行替身:让任务按指定的顺序完成、超时、抛异常、被取消, 然后检查并发名额有没有回到该有的值、终态报告有没有发出来、队列有没有补位。 台账部分用临时库验证唯一键更新和字段限制, 恢复部分存一份检查点再起一个新实例,核对上下文和累计预算对不对得上。 模型能不能把业务做对是另一件事,要靠任务样本回放,两者不能互相替代。

重复的活不许再让模型想一遍

探索阶段让模型决定下一步是对的,因为那时候还不知道活长什么样。 但一批同构的对象已经有了稳定做法之后, 再让模型每轮重新决定「取哪一条、失败等多久、下一批派几个」, 既费钱又不一致——同一批活今天这么派、明天那么派,出问题没法归因。

所以稳定下来的流程会沉淀成一段脚本: 查台账里还没做完的行,派一批,等报告,再查还剩多少,直到收敛。 执行器负责并发、分通道限速、失败退避、按轮熔断、最后把没解决的行汇总出来。 模型负责理解目标、定处理方法、处理异常决策,脚本负责把已经确定的事重复做对。

于是收口有两条路。异构任务走报告驱动:子 Agent 的终态报告变成主 Agent 新一轮的输入, 由它决定汇总还是补派。同构批量走台账驱动:不看谁在跑,只看还剩哪些行没做完。 这两条路判据不同,但都收敛到同一个地方——台账。

限速做了两层。一层是并发信号量,管同时有几个在跑; 另一层是速率门,管单位时间最多发几个请求。

只有第一层的话,外部接口会在并发拉满的瞬间被压垮; 只有第二层的话,突发流量照样能在一秒内把配额打光。 两层之间还有次序:先过信号量再过速率门,被挡下来的请求退回队列,而不是直接丢掉。

失败退避支持指数和线性两种节奏,按轮次的失败比例熔断, 反复失败仍没解决的行进死信,最后统一交给主 Agent 或者人去看。 这里有个该记的坑:声明的分块大小如果超过并发上限, 会被静默压到上限,只留一条日志—— 看起来配置生效了,实际没生效,这种沉默比报错贵。

记忆这块我重做了一遍

记忆是这套系统里我投入最多的一块,也是最容易被做歪的一块。 先说原来那版是怎么跑的:一个后台反思 Agent 在会话告一段落时异步起来, 从主 Agent 的对话里抽「值得长期留着的东西」,写成带描述头的 Markdown 文件; 下一次任务来的时候,扫描这些文件的描述, 用一次小模型调用挑出几个相关的,整篇读进来拼进系统提示词。

这版能用,而且便宜、人能读、改起来方便。但它有五个地方顶不住。

顺便说清反思是怎么触发的,因为它决定了写入侧的质量上限。 反思是异步的,不阻塞主会话,有冷却时间,短反思和长反思交替进行: 短反思只处理最近几轮里冒出来的新东西,长反思才会去合并重复、清理过期文件。 上下文被压缩的时候会额外触发一次长反思—— 压缩意味着一批原始对话马上就要读不到了,那是最后一次把它们变成记忆的机会。

第一,一次性的和长期的混在一起。 「这次报告先给结论」是当前任务的约束,「以后报告都附来源」才可能是长期偏好, 两种话在文件里长得一模一样,没有任何字段区分。 结果就是记忆库越用越脏,里面塞满了只对那一次成立的东西。

第二,没有来源坐标。一条记忆为什么成立,说不清; 哪天发现它错了,也不知道该去改哪一条、该不该连带改别的。 记忆系统里最贵的不是写错一条,是错的那条你改不掉。

第三,冲突全靠模型自觉。原来的做法是在反思的提示词里写一句 「先列出现有文件,读疑似冲突的,优先更新已有文件」。 这是 best-effort:没有版本、没有事务、没有检测器, 模型今天记得就合并了,明天忘了就多出一条相反的。

第四,检索质量取决于描述那一行写得好不好。 描述里没提到的实体,正文里就算写得再详细也永远召不回来。 于是「用户档案」这种文件只能靠一个特殊规则强行置顶, 否则「谁是谁」这类问题一次都答不对。

第五,没有使用反馈。这条记忆到底有没有被读到、有没有真的用上、 用了之后有没有被人纠正过,一概不知道。 一个没有反馈的记忆库,只能靠人定期翻,翻不动就烂在那里。

重做的核心是一条判断:按可靠程度分层,不按存储介质分层。 原来那版其实也是分层的——会话一层、文件一层、提示词一层—— 但那是按「存在哪」分的,没有一层回答「这条能不能信」。 新方案的三层回答的正是这个。

证据是不可变引用:用户原话、子 Agent 的报告、 工具返回、执行收据、人工纠正。 证据不等于事实,它只是「有人这么说过、出过这么个结果」。 候选是从证据里抽出来、还没验证的结论,它可能重复、可能冲突、 可能只是这一次成立、可能本来就是错的。 生效是带作用域、证据引用和生命周期状态的权威表述,只有它能被召回。

索引、向量、拼进提示词的那段文本,全部是派生视图,不拥有真相。 这句话后面会反复用到:它决定了检索结果必须回查主记录, 也决定了索引更新失败不应该让记忆不可用。

生命周期是从候选走到生效,再走到被取代、过期、遗忘,另有一个否决态。 关键在替换生成新版本,不覆盖旧值,并且把变更原因一起记下来。 一条约束为什么改了,三个月以后还有人能查到,这比改对了更重要。

作用域从会话到编排实例、项目、主 Agent、再到用户全局,越具体越优先。 这里有一条硬规矩:子 Agent 可以产证据和候选,但写不了全局。 一支子任务在某次抓取里看到的价格,带日期、带证据,也只是一条业务结果, 不能直接升格成跨任务的长期事实。跨任务的记忆必须由主 Agent 或专门的整理流程确认。

准入是分级的,来源决定了它进来就是什么身份。 用户明说的,没有歧义就直接确认;人工纠正,立即确认或替换; 同一段流程反复执行成功的收据,算高置信候选。

单次子 Agent 报告只是候选,需要佐证或者复核; 模型自己推理出来的东西置信度最低,多数时候直接丢掉。 涉及安全、权限、定价、不可逆业务规则的,一律要求人工确认—— 这类东西错一次的代价,远超少记一条的代价。

召回的顺序是固定的几步。先按作用域和访问控制过滤, 再只保留生效态且落在有效期内的,然后词法与语义两路并行召回。 这里每一步都在缩小范围,越往后越便宜。

融合排序之后按上下文预算裁剪,最后注入时只给结论和证据标识,全文按需再读。 这个顺序里,权限和有效性在最前面,相关性在最后面—— 一条越权的记忆再相关也不能出现,一条过期的记忆再像真的也不能用。

两路召回为什么都要。公司名、编号、版本号这类东西要求原词命中, 词法检索更可解释,也更容易核验; 同一个偏好换一种说法的时候,关键词可能一个都不中,语义检索能把它捞回来。 两路的分数不在同一个尺度上,直接相加很依赖手工校准, 模型或数据一变就失真,所以融合用的是名次而不是分数: 两路都靠前的排得更高,某一路没进候选就不贡献。

融合之后还有一步不能省:回查主记录。 取到前几条之后,要按标识回到主表确认它还是不是生效态、还在不在有效期内。 因为索引是派生数据,里面可能残留旧版本,异步更新也可能还没追平。 一条内容在两路都排第一,只说明它容易被召回,不说明它是对的。

更新走的是一个六步事务:写新行、建版本链、旧行退出生效集合、 重建索引行、投一个异步的向量更新任务。 关键在最后一步——向量更新失败不阻塞主流程。 新版本可以立刻通过词法路径被用到, 语义那一路在追平之前暂时不完整,但错误的旧版本已经不会生效了。 先保证已知错误不再起作用,再慢慢恢复完整的召回能力,这个顺序不能倒过来。

冲突检测分两步走,避免误判。 先在同一作用域同一类型的候选里找语义相近的, 相近到一定程度之后,再判断两句话的语用是不是相反—— 一边是「应该 / 必须 / 需要」这类,另一边是「不应该 / 不必 / 避免」这类, 两条同时命中才判矛盾。只看相似度会把同一件事的两种说法误判成冲突。

判成冲突之后的裁决按来源优先级走:用户明确说的最高, 其次是有证据支撑的执行结果,最后是模型的推测。 判不出来怎么办?保留冲突状态,召回时把「存在冲突」这个标记一起给主 Agent, 让它重新核验,而不是系统偷偷挑一个。 记忆系统最忌讳的就是替用户做判断题——它挑错了,连痕迹都不会留下。

还有一条容易做错:多个 Agent 引用了同一个来源,不能当成多份证据。 同一个网址、同一份文档的同一个版本,本质上是一个来源, 只是被几支任务分别找到了。来源要靠标识归并, 不同独立来源之间的相互印证才有比较意义,还得留意它们是不是互相转载。 召回排序可以用来源数和新鲜度做辅助信号, 但不能让并行任务重复引用同一份东西就把置信度人为放大。

治理是三个环节一起做的。写入侧做来源、作用域、重复、冲突、敏感信息检查; 召回侧做权限、版本、有效期、相关性和预算; 后台定期把高度相似的重复项合并、把过期的归档、 把反复被纠正的降权或失效,顺手清掉孤立的向量。

召回错了怎么定位,是这套设计里必须回答的问题,否则治理就是空话。 每次召回都记下候选、两路的名次、过滤掉了什么、最终注入了哪几条、对应哪个版本、 属于哪个任务。出问题的时候顺着这条记录往回走, 能分清是词法那一路漏了、语义那一路误召回、 融合的时候名次算错了、版本过滤没生效, 还是模型拿到了对的记忆却用错了地方。 分不清层次,就只能笼统地怪「记忆不准」,下次还会再犯。

使用后要记反馈:这次召回了哪几条、有没有被采用、有没有被纠正。 但反馈只用于调排序和做治理,不当事实证明。 一条错误记忆被高频召回来,不代表它变对了; 把采用次数当正确性,等于给高频错误发奖励。

那能不能让模型自己判断记忆对不对?我的答案是不行,至少不能只由它判断。 模型可以帮忙抽取、归类、发现疑似冲突, 但它本身就是错误来源之一,让它当唯一的发布依据,等于让被告当法官。

能程序化约束的——作用域、版本、唯一键、状态、有效期——全部交给系统; 需要语义判断的保留来源和待确认状态,不直接生效; 影响大的偏好和外部事实要求人工过一道。 这样出了问题还能追到是哪次写入、哪条证据导致的,改得动。

最后说清它和另外两件东西的关系。恢复靠会话、游标、运行时状态和台账, 目标是把这次任务接着做下去;记忆靠范围和版本治理,目标是让下一次少走弯路。

恢复的时候不能把长期记忆当成这次的业务进度,记忆写入也不能绕过台账的验收。 三条链各管一段,混起来的话,就会出现「为了恢复执行,把过期记忆重新塞回上下文」这种事。

一句话概括这次重做:记忆不是聊天记录的摘要,也不是外部资料库。 它装的只有一样东西——下一次还能用的、且说得清为什么能用的东西。

这套东西最后留下来的判断

编排里最值钱的是准入判断。少开一个不该开的子 Agent, 比把调度做得更聪明收益大得多。这条在四个月里被反复验证过, 每次系统出问题,回头看都是「这个本来不该起来」。

收口要唯一。并发、停止、工具面如果散在好几个 if 里, 「为什么没跑」就永远有歧义,排查成本按指数涨。 一个函数解决一个判定,是这条线的全部要义。

部分失败是常态,不是异常。系统的默认姿态应该是「保留已有的、说清缺的」, 而不是「重来一遍」。这决定了报告要分状态、台账要分字段、 恢复要分粒度——所有复杂的地方都是从这个默认姿态长出来的。

业务真相写在台账里,不写在进程状态里。进程随时会挂, 挂了之后还能问一句「这批做到哪了」,这才叫可运维。

确定性流程不该每轮都让模型想一遍。模型该做的是应付变化, 重复的事交给脚本,两边各自省下来的成本都比对方多。

记忆分层是给真假打标记,不是给存储分级。 证据、候选、生效这三层一旦分清,污染路径就收敛了; 剩下的检索、排序、裁剪都是实现细节,换任何算法都不影响这套治理成立。

最后一条和这个项目里所有别的活是同一条: 能力有多强是模型的事,能不能被验证、能不能被推翻,是工程的事。 把后面这件事做实,前面那件事才有被讨论的资格。

还有一条是关于顺序的:可观测性要先于能力扩展。 每次想加新能力之前,先把对应的回放案例和观测补上—— 并发竞争、超时、取消、重启、外部结果未知,这五类案例先能跑, 再谈增加新的 Agent 类型或者新的记忆结构。 没有这套基线,改动带来的好坏只能靠感觉判断,而感觉在这类系统里几乎没有对过。