Agent 难的部分
不在模型里
模型每半年强一截,Agent 落地的难点却几乎没怎么变过。 这里记录的是我自己动手把一套 Agent 系统从零做完一遍之后, 关于「模型之外那些东西」的一些具体判断——上下文怎么组装、记忆什么时候不该用、 技能多了以后怎么不塌、每一分钱花在哪。
如果你只有一分钟
- Agent 产品做不成,绝大多数不是模型不够聪明,是 harness 太薄。
- 上下文工程不是「把能塞的都塞进去」,是每次调用前重新决定塞什么。
- 记忆系统真正难的不是存和取,是判断什么不该被注入——我做了五种记忆,其中一种至今只存不用。
- 做出 40 个技能不难,难的是让第 41 个不破坏前 40 个。
- 成本必须在调用发生的那一行代码里记账。事后是算不出来的。
- Agent 的 bug 大多不是代码 bug,所以要存的不是日志,是当时那份完整的 prompt。
§01上下文是会腐坏的
几乎每个做过 Agent 的人都遇到过同一件事:一段对话刚开始的时候,Agent 表现得很好; 聊到二三十轮,它开始重复自己、忘记刚说过的约束、把很久以前提过一嘴的偏好当成当前指令。
第一反应通常是「上下文窗口不够」。但把窗口从 8K 换到 200K 之后, 问题往往没有消失,只是推迟了。
窗口变大解决的是「装不下」,解决不了「装进去的东西信噪比在下降」。
这是我认为最值得先讲清楚的一件事。一段长对话里,真正与当前这一轮相关的信息, 比例是随轮次单调下降的。你把全部历史原样塞进去,模型每一轮都要在越来越多的 无关内容里重新找一遍重点。它不是记不住,是被你喂的东西干扰了。
顺着这条线往下想,会发现一串彼此关联的工程问题,它们和模型能力基本无关:
- 上下文腐坏——历史越长,信噪比越低,而不是信息越多。
- 记忆污染——一条被错误提取的长期记忆,会在之后每一轮里持续起作用。 读取出错只影响这一次回答,写入出错影响之后所有回答。
- 成本不可见——demo 阶段没人看 token。等到有真实用量了, 你会发现根本说不清钱花在哪个环节,因为当时没在调用点记账。
- 过程不可观测——用户看到的是一个转圈的图标。出错时, 你既不知道它错在哪一步,也无法复现。
- 技能边界爆炸——每加一个技能,意图识别要区分的歧义对是平方增长的。 第 5 个技能很轻松,第 40 个会让前面 39 个一起变差。
这几件事,换个更强的模型都不会自动变好。它们构成的那一层,我习惯叫它 harness。
§02Harness:模型之外的那五层
我自己的系统里,一条用户消息从进来到吐出第一个字,中间要穿过五层。 每一次 LLM 调用都完整地穿过它们,没有捷径。
接下来四节分别讲其中几层,挑的都是我觉得最反直觉、或者踩坑最深的地方。
§03每次调用前,重新决定塞什么
上下文组装最容易被写成一个模板:一个大字符串,把 system prompt、用户画像、 历史消息、附件内容按顺序拼起来。能跑,但很快会变成没人敢改的一坨。
我最后的做法是把它拆成一条管线,每一段上下文是一个独立的块, 自己决定这一轮要不要出现、出现多少:
一个具体的坑:时态
这个坑我想单独讲,因为它非常具体,而且几乎所有人都会踩一次。
模型不知道「现在几点」。如果你把历史消息原样拼进去,它看到的是一串没有时间刻度的对话。 于是用户三天前说的「我明天要交」,会被当成明天还没到。 用户上周提过一次「最近在看 A 项目」,两周后它还在拿 A 项目当上下文。
解法很朴素:每条历史消息都带上绝对时间戳,并且在 prompt 里明确写清当前时刻。 朴素,但不做的话,长期对话里的时态混乱会持续制造那种「它好像有点笨」的体感, 而你很难从错误回答倒推出根因是这个。
§04五种记忆,其中一种故意不注入
我的系统里分了五类记忆:核心画像、对话记忆、话题记忆、房间画像、需求记忆。 提取全部是异步的——在主链路上做记忆提取会直接拖慢首字响应, 而记忆的价值在下一轮,不在这一轮。
真正想说的是第五类。需求记忆至今只存不用。 它被提取、被向量化、被存进库里,但从来不注入任何一次调用。
因为需求是所有记忆里时效最短的一种,而记忆系统没有遗忘机制。
用户说「我想做个电商后台」,两轮之后他可能已经改主意了。 但这条记忆一旦被注入,Agent 会在之后很多轮里固执地把话题往电商后台上带。 用户得反复纠正它,而每一次纠正又会被提取成新的记忆—— 你会得到一个越来越拧巴的画像。
这就引出我认为记忆系统里最重要的一条:
读取出错,只影响这一次回答;写入出错,影响之后的每一次回答。 所以写入端的判定标准应该显著严于读取端。
「先存着,反正以后可能有用」听起来很安全,实际上是把风险推给了未来的自己。 我现在的取舍是:拿不准的记忆宁可不注入,但可以先存下来—— 存下来是为了分析和回溯,不是为了喂给模型。这两件事应该分开决策, 而大多数实现把它们耦合在了一起:存了就一定会用。
§05第 41 个技能
技能(skill / tool)数量上去以后,会遇到两个独立的问题,很多人把它们混为一谈: 一个是意图识别选不准,一个是代码没法维护。
选不准
技能数量增加时,意图识别要区分的两两歧义对是平方增长的。 「写一份周报」和「写一份文档」、「做个图表」和「做个思维导图」—— 技能少的时候这些边界很清楚,多了以后开始互相渗透。
我的做法是不假装自动路由能解决一切:意图识别之外,永远保留手动指定的通道。 用户可以直接点名要哪个技能。这条通道不只是兜底,它还是最好的训练信号来源—— 用户手动纠正的那些 case,恰好就是自动路由做错的地方。
没法维护
这一半靠一个二分解决:技能分成两类基类, 一类依赖房间上下文,一类是纯输入输出。
| 有状态技能 | 无状态技能 | |
|---|---|---|
| 依赖 | 房间历史、成员、记忆 | 只依赖入参 |
| 可测试性 | 要构造完整房间环境 | 给一组入参就能测 |
| 能否并行 | 不能,有顺序依赖 | 可以 |
| 能否复用 | 绑定在会话里 | 可以被其它技能直接调用 |
划清这条线之后,绝大多数新技能落在无状态那一侧,而无状态技能的边际成本几乎是常数。 真正会互相影响的只剩下有状态那一小撮。
另外两个具体决定,事后看都值:
- 注册制,不是继承树。技能在一份清单里登记,运行时动态调度。 加技能不需要改任何调度代码——这意味着「新增技能」是个纯追加操作, 天然不会破坏已有的那 40 个。
- 配置有明确的优先级链。Agent 实例上的配置 > 技能注册表里的默认 > 兜底值。 听起来平平无奇,但没有这条链的时候,「为什么这个 Agent 的这个技能行为不一样」 是个需要读代码才能回答的问题。
§06按能力路由,不按场景路由
多模型接入几乎是必然的:不同模型在不同任务上的性价比差异很大, 而且你需要在某一家出问题时能立刻切走。
问题在于路由表的键该是什么。直觉上会按业务场景来分—— 「写周报用哪个」「做 PPT 用哪个」「客服问答用哪个」。这个做法在场景只有五个的时候很好用, 场景到五十个的时候路由表就没人维护得动了,因为场景是会无限增长的。
路由的单位应该是能力方法,不是业务场景。场景无限,方法有限。
我的路由表键是一组抽象的能力方法:主对话、意图规划、生成 HTML、 文生图、文生视频、图生文……大概十来个,几年都不怎么变。 新增一个业务场景时,它只需要声明自己用哪个能力方法,不需要在路由表里新增一行。
路由的优先级链是四级:
用户请求覆盖(仅限白名单内允许覆盖的方法)
↓ 未指定
Agent 自身配置
↓ 未指定
账号级配置
↓ 未指定
系统默认
「仅限白名单内允许覆盖」这半句是后来补的,也是个教训: 一开始允许用户覆盖任意方法,结果有人把意图规划也换成了一个很便宜的小模型, 整个 Agent 的行为直接崩掉——但表现出来是「Agent 变笨了」, 排查了很久才定位到根因。 不是所有能力方法都应该开放给用户选。
§07存下每一次调用的完整现场
传统服务的排障靠日志:报错堆栈、请求参数、耗时。 Agent 系统里这套基本不管用,因为——
Agent 的 bug 大多不是代码 bug。代码每次都跑对了,只是模型在那个特定上下文下的输出不对。
这意味着要复现问题,你需要的不是堆栈,而是当时那份一模一样的完整 prompt。 差一个字都可能复现不出来——而那份 prompt 是由上下文管线在那一刻动态组装的, 依赖当时的记忆状态、当时的历史长度、当时的 token 预算。事后重建基本不可能。
所以我的做法是每一次 LLM 调用都完整落库:完整的 prompt、完整的 completion、 token 用量、耗时、实际使用的模型、折算出来的费用。不采样,不截断。
这个决定的代价是存储会涨得很快,好处是任何一次「它为什么这么答」都能精确回溯到 那一次调用的现场。这笔账我算得很清楚:存储很便宜,而排查一个无法复现的 Agent 行为异常,成本是没有上界的。
顺带,费用也是在这里算的。每次调用结束时按 token 单价折算并结算—— 必须在调用发生的那一行记账,不能事后从日志里聚合。 因为一旦有了模型降级、重试、多路并发,事后聚合出来的账永远对不上。
行为埋点:一个容易搞错的三分
另一半可观测性是用户行为侧。这里我踩过一个很典型的坑,值得写下来: 埋点里有三个概念必须分清,事件类型、点位、属性。
| 概念 | 是什么 | 基数 | 例子 |
|---|---|---|---|
| 事件类型 | 发生了哪一类行为 | 受控枚举,几年不变 | click / submit |
| 点位 | 发生在哪个按钮、哪个区块 | 自由增长 | hero_cta |
| 属性 | 用于分组和度量的维度 | 自由增长 | plan=pro |
错误的做法是每加一个按钮就新增一个事件类型。 它会让事件类型的基数失控——而这一列通常是低基数列并且在排序键上, 基数一失控,整张表的压缩率和查询裁剪优势就一起没了。 判断标准很简单:名字里带了具体页面、按钮或业务对象的,它就是点位,不是事件类型。
§08Agent 和人是同一张表
这是我在这套系统里做过的最有意思的一个决定,也是回头看收益最大的一个。
Agent 和用户存在同一张成员表里,只用一个类型字段区分。 消息也一样:用户消息和 Agent 消息是同一张表的两个子类型。
做这个决定的时候它看起来只是个建模偏好。实际的后果是: 群聊里 @ 一个 Agent 和 @ 一个人,走的是完全相同的代码路径。 权限、已读、引用、转发、消息删除、成员列表——所有这些功能写一遍就同时支持了人和 Agent。
如果当初把 Agent 建成一个独立的实体,每一个协作功能都要写两遍, 而且两边的行为会缓慢地漂移,最后变成两套需要分别维护的语义。
多 Agent 协作不是一个需要专门设计的功能。如果 Agent 从建模上就是一等公民成员, 它是免费的。
§09XTeam
上面所有这些判断,都不是想出来的,是做出来的。它们来自 XTeam—— 一个我自己从零写到线上的多 Agent 工作台。
它长什么样:多端 IM 交互(iOS / 桌面 / Web),多人多 Agent 在同一个房间里协作, 群内共享文件与知识片段,Agent 的执行过程以实时日志流的形式对用户可见。 技能覆盖对话、联网检索、文档与表格生成、图像生成等等, 既可以自动路由也可以手动点名。支持私有化部署。
但比功能清单更值得说的是它的几个设计取舍:
- 执行过程默认对用户可见。大多数产品把 Agent 的中间步骤藏起来, 呈现一个「正在思考」。我选择把它流式地摊开。 因为 Agent 不可避免会做错,而用户能看到它错在哪一步的时候, 愿意给的耐心会高得多——他会纠正你,而不是关掉。
- 共享上下文是房间级的,不是用户级的。群任务空间里, 文件和知识片段属于这个房间,房间里的人和 Agent 共享同一份。 这让「多个人和多个 Agent 一起干一件事」成为默认形态,而不是拼接出来的功能。
- 一键部署,零外部依赖。可以完全跑在自己的机器上。 对于要处理内部资料的场景,这不是加分项,是前提。
如果你想看看它实际跑起来是什么样:xagent.world。
§10在看的东西
这个领域里我认为值得认真读源码或文档、而不只是看看 demo 的几个:
- Claude Code 目前把「Agent 在真实代码库里干活」这件事做得最扎实的一个。 值得注意的是它在上下文管理上的克制——不是尽可能多塞,而是尽可能准。
- MCP 把工具接入标准化的一次认真尝试。真正的价值不在协议本身, 而在于它让「工具」变成了可以被独立分发和复用的东西。
- LangGraph 把 Agent 流程显式建成图。我不完全认同这个抽象层次, 但它对「控制流该由谁决定」这个问题的回答是明确的,值得对照着想。
- Dify 工作流编排的产品化做得很完整。 我用它做过实际项目,它的边界在哪、什么时候必须自己写,体感很清楚。
§11还没解决的问题
比起讲已经做成的事,我更愿意讲还没想清楚的。以下每一条我都真的卡在那里:
-
记忆的遗忘与冲突消解
我有五种记忆,但没有一种会遗忘。用户的偏好变了、需求推翻了、 身份换了,旧记忆仍然在那里起作用。置信度衰减听起来是答案, 但「衰减多快」没有原则性的依据,调参就是在猜。
-
多 Agent 协作里的责任归属
三个 Agent 协作产出一个错误结果,怎么定位是哪一环的问题? 单个 Agent 我有完整调用现场,但跨 Agent 的因果链目前还是靠人读日志拼出来的。
-
技能路由的评测
我知道路由会选错,但我没有一个可靠的指标来衡量「比上个月好了多少」。 用户手动纠正的次数是个代理指标,但它同时受技能数量和用户习惯影响, 不够干净。
-
长期运行的 Agent 该怎么被信任
一个跑在定时任务里、无人值守的 Agent,出错时没有人在场纠正。 要给它多大的权限边界、以及用什么机制让人相信这个边界是有效的, 我目前的答案还很粗糙。
如果你对其中任何一条有想法,或者你正好也卡在同一个地方,我很想聊聊。