最近在做客服工单助手Agent的上线,整个后端团队连着加了小半个月班。上线前一晚我失眠了,因为Agent这东西和普通接口完全是两回事——它有自己的“脑回路”,记忆会丢、规划会跑偏、输出会乱码,任何一个环节出问题,线上就是事故。这篇文章不聊概念,只讲我们在生产环境中真实踩过的三个大坑:记忆、规划、输出,以及Java技术栈下我们是怎么填坑的。如果你正准备把Agent接到自己的Java服务里,这些内容大概率能帮你提前避开我们趟过的雷。
1. 项目背景:Agent上线为什么是“踩坑之旅”
1.1 这个Agent项目到底在做什么
公司内部有一套客服工单系统,之前全靠人工处理用户消息:查订单、答售后、建工单、排优先级,一天几百条消息,客服团队忙得脚尖不落地。我们后端小组接的需求是上线一个工单助手Agent,自动消化掉一部分重复咨询,比如物流进度、退换货入口、订单状态查询。技术栈是Java 17 + Spring Boot 3 + Redis + MySQL,大模型走的是公司内部的 MoE 接口。
Agent的架构并不复杂:用户消息进来,先做意图识别,然后Agent规划执行步骤,按需调用订单查询、工单创建等工具,最后生成回复内容返回给前端。带人话讲就是给大模型配一套“手”,让它能查系统、能干活,而不只是聊天。这个链路拆开来看每一步都有成熟方案,但真正把它们组合起来推到生产环境,才发现调试成本比传统接口高一个量级。
1.2 为什么Java团队尤其容易中招
我们踩坑之后复盘,发现Java后端团队做Agent落地,有几个天然容易被坑的原因。
第一是强类型系统。大模型返回的都是字符串,Java这边要做严格反序列化,类型对不上直接抛异常,而且抛得毫不留情。Python团队拿到模型输出可以随便处理,我们这边拿Gson或者Jackson一解析,字段缺失、枚举不匹配、类型漂移,一个都逃不掉。
第二是契约式思维。Java后端习惯了接口契约——入参出参定好,双方按规矩走。但大模型输出是概率事件,今天返回这个格式,明天换个措辞,根本不跟你讲契约。用做传统接口的方式要求模型输出,就是把宝押在概率上,早晚出事。
第三是线上影响的放大效应。Java后端通常承载核心业务链路,Agent一旦出错,影响的不只是聊天体验,还可能是错误工单、错误退款单这类实打实的业务事故。同样的错误在Demo里重启一下就行,在生产环境就是客服群里截图轰炸。
这三个原因叠加,决定了Java团队上线Agent的容错设计必须比“能跑通Demo”严格得多。
2. 大坑一:Agent的记忆,上线当天就“失忆”
2.1 现象:客服回复前后矛盾,用户被踢皮球
灰度上线第一天就出了事故。用户上午问“我的订单什么时候发货”,Agent回复“订单已出库,预计明天送达”。下午同一个用户又问同样的问题,Agent的回答变成了“抱歉,我没有找到您的订单信息,请确认账号是否正确”。用户当场炸毛,客服群里截图质问,产品经理脸都绿了,直接跑到我们工位来问怎么回事。
问题出在记忆管理上。用户和Agent的对话是多轮的,Agent需要知道“当前用户在问谁的订单、刚才确认过什么信息”,这是会话内的短期记忆;长期来看,它还需要记住用户的历史偏好、过往沟通记录,这是跨会话的长期记忆。我们第一版实现偷了个懒:每次请求把全部历史消息按时间顺序拼在一起发给大模型,同时把用户最近三个月在系统里产生的工单记录也全塞进上下文。结果就是:
- 上下文长度随对话轮次线性增长,跑到第15轮左右就超过模型上下文上限,请求直接报错;
- 大量历史记录包含过时信息,比如用户一周前问过“退款”,这周再问“发货”,模型被旧信息干扰,回答跑偏;
- 三个月工单记录全文塞进去,token成本一个月下来花了几万块,还被老板单独拉去谈话。
不少Java同行在这个阶段的第一反应估计是“这不就是JVM堆内存嘛,不断往里塞对象总会OOM,要做淘汰策略”。方向确实对,但Agent的记忆管理远比JVM的GC复杂——淘汰策略不能简单地“删最早的”,还得权衡哪些信息对当前任务最重要、哪些信息可以压缩成摘要、哪些信息要持久化到长期存储。
2.2 根因:把“一次性上下文”当成了“完整记忆”
我们复盘时发现,问题的根本不在于“历史消息太多”,而在于对记忆的理解太浅。大模型的上下文窗口只是短期工作区,相当于你手里的一张便签纸;Agent要正常运行,真正需要的是三层记忆配合。
第一层是工作记忆,即当前任务正在使用的信息,比如“当前用户ID是12345,正在处理订单20240301”。这层记忆要求精确、紧凑、不能丢,一般放在会话状态里,Java侧可以用ConcurrentHashMap或者Redis维护。
第二层是短期记忆,即对话历史中和当前上下文相关的部分。这层记忆不能无限膨胀,需要按相关性和时间做取舍。比如用户上上轮的闲聊,到第10轮已经不重要了,但用户在第2轮透露的收货地址反而要一直保留。
第三层是长期记忆,需要跨会话、跨天保留。比如用户是铂金会员、他经常用夜间物流、他上次投诉过配送慢。这些信息适合抽成结构化的用户画像存MySQL或Redis,或者做成向量嵌入存到向量库,按语义相似度检索。
我们第一版把所有信息揉成一锅粥全塞给模型,既没分层,也没取舍,自然出问题。这就像把整个仓库的货物全搬到工位上,反而找不到当前要用的那件工具。
2.3 解法:短期缓冲 + 摘要压缩 + 长期检索,三层配合
经过反复调整,我们最终在Java侧做了一个“三层记忆管理器”,核心思路:让短期记忆保持精简,把超窗的旧对话压成摘要,把真正重要的用户信息抽出来存长期库,每次请求动态组装上下文。
第一步,短期记忆滑动窗口。把对话历史分成三个优先级:系统提示语和当前任务上下文最高,永远保留;最近8轮对话次之,尽量保留完整内容但可裁剪;更早期的对话最低,用摘要代替。这个逻辑说白了就是给消息分配权重,权重低的先被淘汰,跟Redis做LRU淘汰的思路一脉相承。
第二步,摘要压缩。每次对话达到滑出窗口的临界点,就把这批对话交给一个专门的“摘要器”模型,让它产出200字以内的摘要,角色提示词就一句话:“你是对话摘要器,提取事实和用户意图。”然后把摘要存到Redis,key是sessionId,value是JSON,结构大致是{"summary": "...", "facts": {"user_id": ..., "latest_order_id": ...}}。下次组装上下文时,先放摘要,再接最近的原始对话。
第三步,长期记忆检索。用户在系统的历史工单、历史投诉记录,不再全文塞给模型。用户进入会话时,先把结构化画像从MySQL捞出来,再按当前意图去向量库检索最相关的历史记录,只把Top3相关片段放进上下文。这一步工程量大了不少,但对回答质量的提升立竿见影。
Java侧的核心类结构大概是这样:
public class MemoryManager { private final StringRedisTemplate redisTemplate; private final VectorStore vectorStore; private final SummaryClient summaryClient; // 大模型摘要调用 private final UserProfileMapper userProfileMapper; /** * 组装最终发给大模型的上下文 */ public ContextBundle buildContext(String sessionId, String userId, String newMessage) { // 1. 长期记忆:用户画像 + 向量检索Top3 UserProfile profile = userProfileMapper.findByUserId(userId); List<MemoryDoc> relevantDocs = vectorStore.topK( embed(newMessage), 3); // 2. 短期记忆:滑动窗口裁剪 List<ChatMessage> recent = getRecentMessages(sessionId, 8); String summary = getSummary(sessionId); // 3. 拼接三层内容 String systemPrompt = buildSystemPrompt(profile); String context = summary + "\n" + formatMessages(recent) + "\n" + newMessage; return new ContextBundle(systemPrompt, context, relevantDocs); } /** * 每轮对话结束,滑动窗口淘汰的同时做摘要压缩 */ public void afterTurn(String sessionId, List<ChatMessage> fullHistory) { if (fullHistory.size() > 20) { List<ChatMessage> oldPart = fullHistory.subList(0, fullHistory.size() - 8); String newSummary = summaryClient.summarize(oldPart); redisTemplate.opsForValue().set("agent:summary:" + sessionId, newSummary); trimMessages(sessionId, 8); } } }代码思路不复杂,但有几个容易漏的细节必须强调。第一,getRecentMessages从Redis的List结构取消息时一定要加事务边界,否则并发请求可能读到半截消息。第二,摘要不是每轮都做,一定要设触发阈值,我们设的是历史大于20轮时才压缩,否则token费用照样爆。第三,向量检索要限制召回条数,我见过不加限制的团队,一次召回50条历史记录塞进去,比全文塞还贵。
2.4 实战经验:记忆层最容易漏的三个细节
会话并发下的串话问题。Agent服务是并发接收消息的,同一个sessionId的多个请求可能同时触发摘要压缩,两个线程同时写summarykey,导致上下文错乱。我们用synchronized锁同一个sessionId的Redis操作,简单粗暴但有效。如果你用Spring的@Cacheable做会话缓存,注意区分cacheName,否则多个session会串key。
长期记忆别只存“用户说了什么”,还要存“系统做了什么”。比如用户上次投诉后,客服给他补发了优惠券,这个“系统动作”不记录的话,下次Agent完全不知道已经给过补偿,会重复补发。我们的做法是把工具调用记录也作为记忆写入向量库,查询时按类型过滤。
摘要质量比想象中重要。如果摘要器丢了关键事实,后面所有轮次都会错。我们给摘要Agent加了强制输出模板,必须输出JSON字段{facts: [], summary: ""},并且做校验,缺facts字段就重试一次。这个规则上线后,摘要质量稳定了很多。
3. 大坑二:Agent的规划,从“自由发挥”到“规范流程”
3.1 现象:简单任务也能绕弯,工具调用死循环
记忆问题解决后,我们安稳了两天。第三天新的幺蛾子来了:一个用户问“怎么申请退货”,Agent开始了它的“自由表演”。它先查了订单,然后调了客服知识库,接着又查了一遍订单,然后居然直接去调了创建工单接口,在没有确认用户是否真的要退货的情况下生成了一张退货处理单。客服那边收到一个莫名其妙的待办,一头雾水。
更头疼的是死循环。有一次Agent处理“查询订单状态”,先调了订单查询工具拿到结果,然后模型决定“我需要再确认一次”,又调了一次;返回之后,它又决定“为了准确性再查一次”。要不是事先设了最大12步的硬限制,这个循环能跑到模型超时为止。整个过程的日志能看到它绕圈,我们却无法运行时打断,因为规划器内部完全是个黑盒。
这种现象在业内有个更形象的形容:模型“假装在思考”。它输出一堆推理步骤,每一步看起来都合理,但实际上没有真正推进任务。背后的原因是规划链路缺少校验、缺少约束、缺少终止条件。传统Java接口的逻辑是确定的,if/else分支写清楚、循环有明确的退出条件;Agent的规划是模型自由生成的,每一步都可能偏离主线,每层循环都可能出不来。我们这群Java程序员最擅长的就是给代码加边界条件,结果在Agent这层把老本行丢了。
3.2 根因:ReAct循环缺少“边界控制”
我们最初的规划实现,本质上是标准的ReAct模式——Reason and Act,模型观察工具返回结果,思考下一步,执行动作,再继续观察。这个模式本身没问题,但生产环境的难点在于给这个循环加边界。踩的坑归纳起来有三类。
第一类是输入边界缺失。模型调用工具时参数是它自己生成的,我们没有做严格校验。有一次模型调用订单查询工具,把orderId传成了“订单号:20240301”这种带前缀的字符串,Java侧拿到后直接解析失败,Agent整体报错。如果工具入参是强类型,模型生成的值未必符合类型约束,这是Java技术栈非常典型的坑。
第二类是输出边界缺失。子任务执行完,模型自己判断“是否完成”,但我们没有给这个判断定客观标准。模型觉得“我调用了查询接口,任务就算完成”,可实际上查询结果还没组装成用户回复,任务根本没有闭环。
第三类是流程边界缺失。没有设置最大步数、没有超时熔断、没有子任务依赖关系校验。模型可以在任意步骤之间跳跃,也可以反复执行同一个动作。后果就是日志里出现大量重复调用,费用和延迟一起上涨。
3.3 解法:任务模板 + 工具注册表 + 熔断机制
我们把规划层改造成“半约束”结构,核心思路:任务拆解仍然由大模型完成,但拆出来的步骤必须经过校验器,工具调用必须走注册表,整个循环必须带熔断。改造之后,Agent的自由度还在,但不再失控。
第一个改动是引入任务模板。对常见业务场景,在代码里预定义标准流程。比如“退货咨询”的固定步骤是:确认用户身份 -> 查询最近订单 -> 判断订单是否符合退货条件 -> 生成回复或创建工单。模型要做的事情是在这个模板上填充具体参数,而不是从零自由发挥。每个模板节点都标注“输入参数来源”和“输出校验规则”。这个改动的收益立竿见影:同一场景,模型犯错的概率降了一个量级,因为发挥空间被限制住了。
第二个改动是工具注册表。把Agent能调用的所有工具集中注册,每个工具用JSON Schema声明入参格式,比如orderId必须是字符串且匹配ORD-前缀。模型生成工具调用参数后,先过一层校验器,不合格就直接返回错误信息让模型重新生成。注册表还记录每个工具的QPS上限和敏感级别,比如“创建工单”这类写操作,默认只能在任务模板指定节点调用。这个设计很值得Java团队借鉴,说白了就是把接口参数校验那套思路搬到了模型层。
第三个改动是熔断机制。给每个规划循环加三个硬边界:最大步数(设12步)、单轮超时(45秒)、重复动作检测(连续3次调用同一工具直接熔断)。一旦触发熔断,Agent停止自主执行,把当前状态转移给人工客服接管。前两个边界好理解,第三个重复动作检测是吃了几次死循环的亏之后加的:每次工具调用都记录toolName + 入参hash,连续重复判定为“模型绕圈”,直接打断。
规划校验器的核心代码大致如下:
public class PlanValidator { private final ToolRegistry toolRegistry; public ValidationResult validateStep(PlanStep step, TaskContext ctx) { // 1. 步骤是否在任务模板允许的范围内 if (!ctx.getTemplate().allowStep(step.getAction())) { return ValidationResult.reject("step not allowed: " + step.getAction()); } // 2. 工具参数是否满足JSON Schema ToolSpec spec = toolRegistry.get(step.getToolName()); if (spec == null) { return ValidationResult.reject("unknown tool: " + step.getToolName()); } Map<String, Object> params = step.getParams(); List<String> errors = JsonSchemaValidator.validate(spec.getInputSchema(), params); if (!errors.isEmpty()) { return ValidationResult.reject("param error: " + String.join(",", errors)); } // 3. 前置依赖是否满足,比如创建工单前必须先查订单 for (String prereq : spec.getPrerequisites()) { if (!ctx.hasCompleted(prereq)) { return ValidationResult.reject("prerequisite not satisfied: " + prereq); } } return ValidationResult.pass(); } }这三个改动里,任务模板的收益最大。很多Agent框架强调“让模型自由规划”,但生产环境恰恰相反:业务规则是确定的,就不该让模型重新发明轮子。这跟写Java代码一样,能用静态类型约束的,就不该靠运行时判断兜底。
3.4 规划层踩坑实录:一次“聪明反被聪明误”的故障
我想单独记录一次故障,因为它的背景值得所有Java后端工程师警惕。
故障发生在一个周末,线上突然出现一批异常工单,内容几乎一样:“用户申请退款,Agent已同意退款并创建退款单。”但实际上,我们的退款审批流程要求必须人工复核,Agent根本没有权限直接创建退款单。问题出在任务模板配置:我们把“退款咨询”和“退款执行”两个模板节点搞混了,模型在处理“咨询”场景时,错误匹配到了“执行”模板;又因为工具注册表里“创建退款单”工具的权限级别设置为“任意节点可调用”,结果Agent就自己把退款单创建了。人类客服处理退款还要层层审批,Agent一秒钟可以批量制造几十个错误工单。
这个case里,Agent没有“特别聪明”,它只是严格按照配置去执行。但它对生产环境的影响比人类更稳定——一旦配错,它会以稳定的速率批量执行错误操作。我们紧急补了三道防线:写操作类工具必须加confirmedByUser参数、模板节点跟工具做白名单绑定、写操作工具QPS限制为每分钟1次。这里给个操作建议:凡是Agent能触达的“写操作”,上线前必须逐条过一遍,问三个问题——这个动作谁能调用?在什么场景下调用?如果模型误调用,后果是什么?三个问题任何一个答不上来,就不该让它开放给Agent。
4. 大坑三:Agent的输出,JSON地狱的求生指南
4.1 现象:模型拍胸脯说返回JSON,反序列化却在报错
第三个坑完全是Java程序员的“专属痛”:大模型输出解析失败。
我们的Agent最终要给用户生成回复,也要给前端返回结构化数据,比如订单卡片、物流轨迹。所以我们要求模型必须返回JSON,格式类似:
{ "reply": "您的订单预计明天送达", "actions": ["show_order_card"], "data": {"order_id": "ORD-20240301", "status": "SHIPPED"} }结果模型返回的JSON五花八门:
- 有的在JSON外面包了一层markdown代码块标记,
```json ... ```; - 有的字段名对不上,我们把
order_id放在data里,它直接输出到顶层; - 有的字段是
null,而我们要求的是必填; - 有的把
"SHIPPED"写成"shipped",枚举不匹配; - 最离谱的一次,它输出了一整段“思考过程”,然后附了一句“下面是我生成的JSON”,整个内容混在文本里。
传统Java接口的报文是契约明确的,序列化和反序列化都有强类型保障;大模型输出恰恰相反,它是自由文本,所谓“结构化”只是模型尽力模仿。我们线上犯的错误,就是拿解析普通HTTP接口的方式,直接ObjectMapper.readValue(response, AgentReply.class),出了错也不知道怎么兜住。
大模型输出的“结构化”本质上是个概率事件。同样是GPT-4级别的模型,给一段复杂的JSON schema,它可能90%的情况返回完全合规的JSON,10%的情况在某个字段上出幺蛾子。没有容错设计的代码,这10%一出现,线上就报错。灰度实践表明,解析错误几乎每天必现,尤其在长尾业务场景里。
4.2 根因:把“自然语言”当成了“契约文本”
很多Agent框架的宣传语把大模型输出包装得很美好,什么“直接输出结构化数据”“一行代码接入”,真正落地的团队才知道这里的坑有多深。根因一句话就能讲清楚:大模型输出的是下一个token的概率分布,不是编译器生成的字节码。它看起来像JSON,不代表一定就是合法JSON。Java团队尤其容易出问题,因为Java的序列化机制是强约束的——类型不匹配直接抛异常,字段缺失直接给null或者直接解析失败。
具体到我们项目,解析失败的场景分四类:
- 格式污染:模型把JSON放在markdown代码块里,或者回复前面带着“好的,以下是您需要的JSON”这类废话。这类最简单,但也最容易忽视。
- 字段漂移:模型输出的JSON字段不在约定位置。比如我们要求
data里放order_id,它输出到顶层;actions要求是数组,它给了字符串"show_order_card",没加方括号。 - 类型和枚举漂移:
status: "SHIPPED"变成"shipped",count: 3变成"3"。Jackson默认配置下遇到这种情况直接抛MismatchedInputException。 - 幻觉字段:模型脑补出schema里根本不存在的字段,前端拿到之后虽然不报错,但业务逻辑会乱。
4.3 解法:两阶段解析 + 宽松schema + 幂等重试
我们结合Java生态的经验,做了四层防护,线上解析成功率从95%提到了99.7%,剩下的0.3%交给人工兜底。
第一层是文本清洗。反序列化之前先做预处理:去掉首尾空白、去掉可能的markdown代码块标记、截断掉“思考过程”等非JSON文本。我的做法是先找到第一个{,再找到最后一个},把中间部分作为候选JSON字符串,这招在大多数情况下有效。当然遇到模型输出JSON数组[...]时要单独适配。
private String cleanResponse(String raw) { String s = raw.trim(); // 去掉markdown代码块标记 if (s.startsWith("```")) { int firstNewline = s.indexOf('\n'); s = s.substring(firstNewline + 1); s = s.replaceAll("```$", "").trim(); } // 提取第一个 { 到最后一个 } 之间的内容 int start = s.indexOf('{'); int end = s.lastIndexOf('}'); if (start >= 0 && end > start) { s = s.substring(start, end + 1); } return s; }第二层是用宽松的反序列化配置。放弃默认的Jackson配置,自定义AgentObjectMapper:把FAIL_ON_UNKNOWN_PROPERTIES关掉,容忍幻觉字段;打开READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE,枚举不匹配时给默认值。数字字段不再强解析,允许字符串自动转数值。这些配置在传统接口里是“坏味道”,但处理模型输出反而是保命符。
第三层是必填字段校验。宽松解析之后,手动校验关键字段,缺了就重试或者兜底。校验逻辑很简单:声明必填字段列表,逐个检查是否为null或空字符串。reply字段缺失时不允许直接返回给用户,必须先走重试逻辑。
第四层是幂等重试。模型输出不合规时,把“上次错误原因”反馈给模型,要求重新生成一次。重试时带上全局唯一的requestId,并设置maxRetries为1,避免模型陷入“重试地狱”。这里有一个重要前提:所有工具调用都必须支持幂等,否则重试会导致重复创建工单这类事故。核心代码大致是这样:
public AgentOutput safeParse(String modelResponse, String requestId) { String cleaned = cleanResponse(modelResponse); JsonNode node; try { node = agentObjectMapper.readTree(cleaned); } catch (JsonProcessingException e) { // 第一轮解析失败,反馈错误,最多重试一次 return retryOnce(requestId, e.getMessage()); } // 必填校验 List<String> missingFields = checkRequiredFields(node, REQUIRED_FIELDS); if (!missingFields.isEmpty()) { return retryOnce(requestId, "missing fields: " + missingFields); } AgentOutput output = agentObjectMapper.treeToValue(node, AgentOutput.class); // 业务兜底:reply为空或actions为空的场景降级 if (output.getReply() == null || output.getReply().isBlank()) { output.setReply(DEFAULT_REPLY); } return output; }这套四层防护下来,解析错误对用户的感知基本消失了。工作量和收益成正比,因为输出解析是所有Agent链路的最后一公里,前面的记忆和规划做得再好,这一公里堵住了,用户拿到的还是错误结果。
4.4 输出层的运维教训:日志里必须能看到“原始输出”
最后补一个运维层面的经验。排查输出解析问题时,最怕的是日志里只有解析错误的异常堆栈,没有模型返回的原文。我们有一次排查了一下午,最后发现是模型在JSON里输出了一行不可见的特殊字符,但日志里被截断了,完全看不出来。
后来我们在日志体系里约定,所有Agent响应必须完整记录三份数据:请求入参摘要、模型原始输出(完整保存,带truncated=false标记)、解析后的结构化结果。原始输出可以写到独立的日志文件或只存Message的payload字段里,方便回放。这个习惯让我们快速定位了很多问题。再往后我们接了一个“输出差异对比”的小工具,每周统计解析失败样本,按错误类型归类,观察模型升级后某类错误比例是否上升。别看这些都是小事,生产环境的坑,很多时候就是靠这种笨功夫一点点填平的。
5. 上线前检查清单:把坑提前踩平
5.1 六个初始化检查项,上线前一小时逐条核对
基于这三个大坑,我整理了一份上线前检查清单。这份清单不是理论推演,是踩过坑后总结出来的操作条目,可以直接拿去用。
第一,记忆层检查。确认长期记忆库索引建好、向量检索召回条数设上限、摘要压缩触发阈值已配置、会话缓存过期策略明确。重点测试多轮对话超过20轮的场景,观察上下文组装后的token数量是否符合预期。
第二,规划层检查。确认任务模板覆盖全部高频场景,工具注册表权限合理,写操作工具全部带白名单,最大步数和超时熔断已配置。用一个“恶意输入”测试用例跑一遍,确认模型绕圈时能被熔断并降级给人。
第三,输出层检查。确认宽松反序列化配置生效,必填字段清单完整,重试逻辑带幂等控制。准备10条包含特殊字符、emoji、超长文本的用例,确认解析不崩。
第四,并发与性能检查。Agent服务是CPU密集型的,一次大模型调用动辄几秒,同步处理会占满线程池。我们用了虚拟线程加消息队列异步化,耗时的模型调用丢到work线程池,快速响应前端“已接收”。上线前一定要压测,确认线程池隔离,不能把Agent的压力传导到其他业务接口。
第五,可观测性检查。检查Agent日志是否包含sessionId、userId、stepId等链路追踪字段,是否记录模型prompt片段和原始输出,是否有独立指标(记忆命中率、规划成功率、输出解析成功率)。
第六,安全与合规检查。Agent可调用的工具范围要最小化,用户隐私信息不能出现在prompt日志里,要给Agent设定“权限边界”——比如不允许调用发送短信、不允许直接修改订单金额。这块建议再单独做一次评审记录。
5.2 监控指标与三条红线告警
上线后比功能更重要的是监控。传统接口监控看QPS、延迟、错误率;Agent服务建议增加几类业务指标。
| 分层 | 核心指标 | 告警阈值参考 |
|---|---|---|
| 记忆层 | 上下文平均token数、摘要压缩触发次数、长期记忆命中率 | token数超过窗口80%告警 |
| 规划层 | 规划总步数分布、工具调用成功率、熔断触发次数、重复动作次数 | 1小时熔断超20次告警 |
| 输出层 | 原始输出解析成功率、重试次数分布、必填字段缺失Top榜 | 解析成功率低于95%告警 |
我们设了三条“红线告警”,任何一条触发都立刻广播到值班群。第一条是熔断触发次数超过阈值,说明Agent可能在批量绕圈;第二条是写操作工具调用频率异常升高,说明可能出现配置错误或幻觉滥用;第三条是解析成功率跌破95%,说明模型版本或prompt可能变了,需要人工干预。这三条红线在灰度第一个月救了我们两次,一次是任务模板配错导致的批量错误工单,一次是模型供应商升级后输出风格漂移,靠告警及时发现才没有酿成大麻烦。
5.3 灰度发布与回滚预案
最后聊灰度发布。Agent系统的发布和传统Java服务有一个关键区别:同一套代码,配上不同prompt或不同模型版本,行为可能天差地别。所以prompt、模型版本、工具注册表配置全部要纳入版本管理,和代码一起走CI/CD,而不是让开发在线上偷偷改。
灰度策略采用“流量切片 + 人工陪跑”:先把5%的真实流量切给新版本Agent,同时所有处理结果同步推送给人工客服复核,后端开发直接看复核结果,标记“正确/错误/不确定”。跑满一周,正确率稳定在92%以上才放开到50%,再跑三天没有异常才全量开放。这个92%的阈值是我们和客服团队一起定的,因为人工客服的响应正确率基准也就90%出头,超过人工水平再放量,是比较稳妥的做法。
回滚预案也要提前想清楚:Agent出问题时的快速回滚不是回滚代码,而是回滚“触发开关”。我们在配置中心加了一个总开关,一键把Agent降级为“仅做意图识别、回复由人工兜底”。这个开关在三天里用过两次,每次都帮我们争取了至少半小时的排查时间。记住,Agent类功能一定要保留一个“无AI”的兜底路径——用户问一句,直接把问题转给人工客服,虽然体验差,但至少系统不会批量制造错误。
这个工单助手Agent上线两个多月,目前处理了平台约68%的重复咨询,人工客服处理量下降了一半多,用户一次解决率从71%提到85%。这个数据不算惊艳,但至少证明这套踩坑后的架构能稳定跑在流量下面。
我个人在实际操作中最深的体会是:做Agent和做传统Java后端,最大的区别不是技术栈,而是思维模式。传统后端追求“确定性”,代码写得越确定,bug越少;Agent恰恰相反,它天生带随机性,我们能做的不是消灭随机性,而是给随机性修一条足够窄的管道,让模型只能在我们允许的范围内发挥。记忆、规划、输出这三道关卡,本质上都在做同一件事:把大模型的自由发挥限制在业务可接受的边界内。边界画得好不好,决定你上线的是“提效工具”还是“事故制造机”。
最后再分享一个后续计划:我们正在把这套校验逻辑抽象成配置化的Agent治理平台,让业务人员也能在可视化界面调整任务模板和校验规则,而不是每次都要开发改代码。等这块做完了,我再回来把配置化过程中的坑也跟大家汇报一遍。