第一版是最直觉的做法:把几个查询方法直接注册成函数给模型调,模型说调什么就调什么。demo 阶段很顺,接了真实流量之后四个问题接连出现。
一是模型给的参数不能信,但代码里到处在信。工具实现是照着「上游是我们自己的代码」写的,参数校验很松。模型开始填出格式错误的订单号、把金额的单位搞错(元和分)、把对话里出现过的另一个订单号填进来。更严重的是有个工具的签名里带了 userId,模型有一次填了别人的——这就是越权,只是当时没人发现。
二是重复调用造成了真实的重复操作。模型在一轮里生成了两次「发优惠券」的调用,或者上一轮超时重试后模型又调了一次。模型不会给幂等键,它压根没有这个概念,所以幂等这件事必须由框架承担,不能指望工具各自实现。
三是错误信息返回方式不对,导致模型反复重试。工具抛异常,框架把异常消息原样丢回给模型。模型看到「Connection timed out」这类文本,判断不出该不该重试,往往就再试一次;看到「余额不足」也再试一次。有一次一个会话里同一个工具被调了十七次。
四是循环没有上限。上面那种会话不仅慢,还在持续花钱,而且监控上完全看不出异常——接口都是 200,只是慢。这类故障最麻烦的地方是它不报错,只是账单在涨。
所以第二版重做了三件事:把工具抽象成统一契约(参数 schema、副作用等级、错误分类、返回裁剪都在契约里声明),把幂等和权限收到框架层(不让每个工具自己实现,实现一百次就会漏一次),给编排循环加四道独立的闸门。
100 与 100.0、可选字段传 null 还是不传,语义相同但哈希不同,幂等直接失效。必须先排序字段、去空值、统一数值格式。这和接口签名验签里的参数排序是完全同一个问题。幂等放在框架层还是工具层,我们选了框架层。工具层实现更灵活,每个工具可以按自己的业务语义定义幂等。但代价是实现一百个工具就会漏一个,而漏掉的那个恰好可能是发券或退款。放在框架层的代价是幂等语义变粗(只能按参数哈希判重,无法表达「同一天只能领一次」这种业务级幂等),但它是默认安全的——新加一个工具只要标注了副作用等级就自动获得幂等,不需要开发者记得写。业务级幂等再在工具内部叠一层。这个取舍的原则是安全机制要默认开启而不是默认关闭。
不可逆操作要不要允许自动执行,我们的答案是「允许但阈值可以调成零」。完全禁止自动执行,Agent 的价值会大幅缩水(用户还是要等人工);完全放开则风险不可控。所以设计成分档:金额低于阈值且规则全通过的自动执行,其余转人工。关键是阈值必须是运行时配置且能一键调到零——出事时需要的是立刻止损,不是等发版。这个开关我们真的用过一次。
为什么不用现成的 Agent 框架,而是自己写编排层。不是说框架不好,是我们需要控制的四件事恰好都是框架的薄弱处:按副作用分级的权限管控、框架层强制幂等、返回值白名单裁剪、以及四道闸门的联动。这些在通用框架里通常要自己扩展,扩展点又不一定够。而编排循环本身的逻辑其实不复杂(几百行),自己写换来的是完全可控。但要说清这不是普适建议——如果需求是快速验证,用框架明显更快;我们是已经确定要上生产才做这个决定的。
踩过的坑一:提示词缓存被一个时间戳废掉了。为了让模型知道当前时间,有人在系统提示的开头加了一行当前时间。系统提示是每次调用都要传的最长的一段,本来靠前缀缓存几乎不花钱,加了动态时间戳之后前缀每次都不同,缓存全部失效,输入侧成本涨了好几倍。功能上完全正常,没有任何报错,是看账单才发现的。修法是把时间放到提示的末尾(变化的内容一律后置),并且加了一个监控指标:缓存命中率。
踩过的坑二:并行工具调用把同一个会话的状态写乱了。模型一轮返回了三个工具调用,我们并行执行以省时间,其中两个都要更新会话状态,互相覆盖了。修法是并行只允许发生在只读工具之间,有副作用的工具串行执行。这条限制损失了一点性能,但省掉了一整类难查的竞态问题。
没做的部分:没做工具的动态发现(运行时从注册中心拉取可用工具)。听起来更灵活,但它和「按场景裁剪工具集」的方向是冲突的——动态发现意味着工具集不可预测,那么评测集就不稳定、权限边界也不好审。我们选择了显式配置。另外也没有做跨会话的长期记忆,当前所有状态都在单个会话的生命周期内。
工具层 P95:压测到 200 并发会话,取工具执行这一段的服务端埋点,不含模型调用时间。这个区分很重要——模型调用的耗时是外部依赖,几百毫秒到几秒,混进来算工具层性能就没意义了。报数字时要明确说「这是工具层,不含模型」,否则面试官会以为你把模型延迟也压到了 60ms,一问就穿。
幂等有效性:构造场景让同一工具在一轮内被调用两次、以及跨轮重复调用,验证第二次是否命中幂等并返回相同结果。更该测的是归一化——故意把参数的字段顺序打乱、数字格式改写、加空值字段,看幂等键是否仍然一致。这才是真正容易漏的地方,只测「完全相同的参数」等于没测。
循环收敛:造四种绕圈场景(同参重复 / 参数微调重试 / 两工具往复 / 不可重试错误反复),分别验证是哪道闸门在第几轮拦住的。要报「第几轮命中」而不是「能拦住」——重复检测在第 2 轮命中和最大轮数在第 10 轮命中,成本差 5 倍,这个差值才是这道闸门的价值。
token 长尾:看单会话 token 消耗的分布,重点是 P99 和最大值,不看平均值。绕圈是长尾现象,平均值完全掩盖它。我们的口径是「长尾从数万级降到与均值同量级」——用「同量级」这种定性说法而不是编一个精确的百分比,因为这个数字随流量结构变化很大,报精确值一被追问统计口径就说不清。
权限收敛的验证不用压测,用红队测试:手工构造让模型尝试越权的输入(诱导它查别人的订单、调没注册的工具、填一个别的租户 ID),验证在执行层被拒绝。这类测试要写成固定用例进回归,因为它防的是最严重的一类事故。
第一版就是最常见的写法:把消息列表存 Redis,每轮把全部历史拼进去,超长了就砍掉最早的几条。上线两周内暴露了四个问题,而且每一个都不是「优化」级别的,是功能不正确。
一是早期的关键约束被砍掉了。用户在第一轮说「我要的是可以开专票的」,二十轮之后这句话被砍了,Agent 开始推荐不能开专票的商品。这是滚动窗口这个方案最典型的故障形态,而且它很难被测出来——短会话完全正常,只在长会话里发生。
二是关键状态靠模型「记住」,不可靠。用户前面确认过的订单号,后面某一轮模型填错了,因为它是从一段长历史里「读」出来的,读错了。把确定的状态交给一个概率性组件保管,这个设计本身就有问题。
三是砍历史砍到了半截。按条数砍,正好把一次工具调用和它的返回结果切开,只留下了调用没留下结果。模型看到自己发起过调用但没有结果,就又调了一次——多花一轮,而且如果那个工具有副作用就是重复操作。
四是同一会话并发写乱了状态。用户连着发两条消息,两个请求同时读改同一份会话,后写的覆盖了先写的。Agent 一轮要跑好几秒,这个窗口比普通接口大得多。
所以第二版的核心思路是不再把会话当成「一个消息数组」,而是当成「一个有结构的状态机 + 一段可淘汰的历史」。三个改动对应三个问题:状态三分(把不能丢的抽出来单独管)、关键字段钉在头部(不参与淘汰,每轮必带)、按轮次裁剪(保持调用与返回成对)。第四个问题用会话级串行化解决。
其中最有价值的判断是第二条:与其把淘汰策略做得更聪明,不如先把「不该淘汰的东西」拿出淘汰范围。做对了这一步,最简单的滚动窗口就够用;做不对,再复杂的记忆机制也会在某一轮丢掉关键约束。
为什么不上摘要压缩,而是选了「结构化状态 + 滚动窗口」。摘要压缩看起来更高级——把早期历史压成一段话,信息保留得更多。但它有三个代价:要额外调一次模型(延迟和成本,而且可能失败)、摘要本身会丢细节(尤其是具体的数字和标识,而这些恰恰是最不能丢的)、不确定性叠加(压缩这一步本身就可能出错,出错了后面全错且无法察觉)。我们的判断是:把「不能丢的」抽成结构化字段之后,剩下能丢的部分其实丢了也没关系,那就没必要为它引入一个不确定的压缩环节。会话真的很长(跨天的工单)才需要考虑分层记忆。
结构化状态放 MySQL 还是 Redis,我们选了 MySQL 为准、Redis 做读缓存。Redis 更快,但这份状态是「不能丢」的定义本身,Redis 的持久化保证不够。而且这份状态需要事后查询和审计(客服要看某个会话确认过什么),MySQL 更合适。对话历史反过来——量大、冷得快、丢了影响有限,放 Redis 加过期,需要长期留存的异步归档到对象存储。按「能不能丢」而不是按「热不热」来选存储,这个判断依据要说出来。
同一会话并发,选了拒绝而不是排队。排队的问题是用户不知道要等多久,而且排在后面的消息可能已经没意义了(用户等不及又发了一条更完整的)。拒绝并明确提示,用户体验上更可控。代价是用户快速连发两条时第二条会被拒,需要前端配合做输入禁用。这个取舍在客服场景下是对的——如果是那种鼓励连续输入的场景,可能需要做消息合并而不是拒绝。
踩过的坑一:抽取字段时用空值覆盖了已有值。某一轮用户没提订单号,抽取逻辑返回了 null,代码直接写库,把之前确认好的订单号覆盖成空了。之后 Agent 开始反复问「请提供订单号」。修法是抽取结果只做增量合并,null 和空字符串一律视为「本轮没有新信息」而不是「要清空」。这个坑在任何有「部分更新」语义的地方都会出现,不是 Agent 特有的。
踩过的坑二:结构化状态渲染得太长,反而挤掉了历史。一开始把所有已知信息都渲染进去,包括查到的完整订单详情,一段就几千 token。修法是明确区分「约束」和「数据」:约束(要专票、只要欧盟版)必须每轮带,数据(订单的全部字段)放工具缓存里按需取。前者只有几十 token,后者可以很大但不需要常驻。
没做的部分:没做跨会话的用户长期记忆(记住这个用户的历史偏好)。技术上是分层记忆的延伸,但它有额外的合规问题——长期存储用户偏好需要告知和授权,而且「Agent 记得你上次说的话」在客服场景下可能让用户不适。这是产品边界问题,不是技术问题,当时的结论是不做。
「早期约束不再丢失」怎么验证:这是个功能正确性问题,不是性能指标,所以用构造用例而不是统计。做法是造一批长会话用例——第一轮给一个明确约束,然后灌入二十轮无关对话,最后问一个会触发该约束的问题,断言回答符合约束。这类用例进回归集,每次改上下文逻辑都跑。报数字时我会说「构造的长会话用例全部通过」而不是编一个准确率,因为这是断言型测试,通过或不通过。
单轮输入 token 中位数:从真实会话日志里统计,按段拆开报(系统提示 / 结构化状态 / 检索 / 历史 / 用户消息各占多少)。要报中位数不报平均值——平均值被少数超长会话拉高了,中位数才代表典型情况。而且要说明对比的基线是什么:我们的口径是「改造前后同一批会话重放的对比」,不是「改造前的线上数据 vs 改造后的线上数据」,因为后者流量结构变了不可比。
裁剪正确性:单元测试级别的验证——构造各种边界情况(预算刚好装下 N 轮、某一轮特别长装不下、工具调用在轮次边界上),断言裁剪后的上下文里不存在「有调用无返回」的轮次。这是个可以静态检查的性质,写成断言最可靠。
缓存效果:统计会话内工具调用的去重率(同一会话内相同工具加相同参数的调用次数 / 实际下游调用次数)。不要报「性能提升了多少」——缓存的收益高度依赖会话形态,报去重率这个直接观测值更诚实,面试官也更容易判断你说的是真的。
并发正确性:用同一会话 ID 并发发起两个请求,断言第二个被拒绝且第一个的状态写入完整。还要测锁超时后的情况——如果第一个请求卡死了,锁到期后第二个能否正常进入,以及此时第一个如果恢复了会不会写脏数据(我们的做法是写入时校验状态版本号)。
第一版的思路是「尽量让 Agent 多答一些」,转人工只是一个失败出口,随便实现了一下。结果上线一周满意度反而比纯人工时期低,复盘出来四个原因,每一个都不在「模型不够聪明」上。
一是硬答。知识库里没有的东西,Agent 也给了一个看起来很有道理的答案。用户照着做,做错了,回来投诉。这比直接说「我不知道」的损害大得多——它不仅没解决问题,还消耗了用户的信任。
二是转人工之后用户要重说一遍。坐席接手时只看到一句「用户请求转人工」,前面聊的十轮、Agent 查到的订单信息全都没有。用户已经描述过一次问题了,现在要再描述一次。「你们的机器人白问了」这句投诉的来源就是这里,而它和模型能力完全无关。
三是转人工入口被藏起来了。产品设定「用户必须先和 Agent 交互三轮才显示转人工按钮」,目的是提高自助解决率。结果是自助率数字上去了、满意度掉下来了——那三轮对已经知道自己需要人工的用户来说是纯粹的折磨。
四是改了提示词之后老问题复发。为了修一类问题调整了提示词,两周后发现之前修好的另一类问题又出现了。因为没有回归机制,提示词是全局生效的,改一处影响所有场景,而我们只测了要修的那一处。
所以第二版重新定位:Agent 的价值上限不由「答得多好」决定,而由「答不了的时候处理得多干净」决定。四个改动分别对应:拒答做成代码层的机制(不依赖模型自觉)、转接做上下文无损、转人工入口永远可见、提示词纳入灰度和回归。
定位选了「分流」而不是「替代」,这个决定影响了后面所有设计。如果目标是替代人工,就必须追求「什么都能答」,那么拒答就变成了失败,团队会不断放宽自动范围直到出事。定位成分流之后,「答不了」是一个正常的、被设计过的分支,只需要答好那部分代价低的问题就有价值。代价是自动化率的天花板不高(一半多一点),但这个方案能真正上线并稳定运行,而追求替代的方案通常在出一次事故后就被叫停了。
考核指标坚持双指标,拒绝只看自助解决率。这一条是我们和产品争论最久的。只考核自助率的后果非常明确:它会驱动团队把转人工入口藏起来、让 Agent 硬答、把「用户放弃了」计为「已解决」。我们最后定的是自助解决率、满意度、以及转人工后的二次解决时长三个一起看。第三个指标是关键——它衡量的是「转接质量」,如果上下文没带过去,这个数字会明显变长。只有把它纳入考核,转接质量才有人负责。
置信度判定放在检索层而不是模型层。另一种思路是让模型自己输出置信度或者「是否在资料中找到依据」。我们试过,问题是模型对自己的置信度估计不可靠,它在编造时往往同样自信。而检索相关度是一个客观的、可观测的数值,用它做阈值判断更稳。代价是会误拒一部分——资料确实存在但表述差异大导致相关度低。这部分我们用查询改写来缓解,而不是放宽阈值。取舍方向是宁可多拒一点,不要硬答。
踩过的坑一:转人工之后 Agent 还在回复。会话状态机漏了「已转人工」这个状态,坐席在回复的同时用户又发了一条消息触发了 Agent,两边同时说话。用户截图投诉。修法是加显式状态并在入口做拦截,同时把这个用例写进回归。这个坑说明会话所有权必须是状态机里的一等概念,不能靠约定。
踩过的坑二:情绪识别过于敏感,把正常用户都转走了。加了情绪识别自动转人工,阈值设得太低,用户说一句「怎么这么慢」就被转人工,人工队列瞬间被打满。修法是把情绪识别的输出从「直接转人工」改成「提升优先级 + 调整话术」,只有明确的强烈负面才转。这条的教训是自动转人工的触发条件也需要限流——它会把压力转移到一个容量有限的资源上。
踩过的坑三:评测集只有正常用例,跑起来一直是满分。最初的评测集是从日志里挑「典型问题」,全是正常场景,任何改动都是 100% 通过,等于没测。修法是补三类用例:知识库里没答案的(考拒答)、问法很偏的(考召回)、以及带诱导的(「你们是不是承诺无条件退款」,考会不会乱承诺)。补完之后通过率降到七成多,这才是有信息量的数字。
没做的部分:没做坐席回复的自动学习(把人工的优质回答自动沉淀成知识)。方向是对的,但它需要人工标注哪些回答值得沉淀,而坐席没有这个动力,硬推会变成负担。这是流程和激励问题,不是技术问题,所以当时只做到了「一键把这段对话提交为知识库候选」,由知识运营同学审核。
「用户重述比例」怎么统计:这是我们衡量转接质量的核心指标。做法是看转人工后用户的第一条消息和转接前的对话是否高度重叠——用文本相似度自动判定,再人工抽样校准。报「大幅下降」而不报精确百分比,因为自动判定有误差,而且这个数字受咨询类型分布影响很大。面试时我会直接说明这个指标是怎么算的以及它的局限,说清测量方法比给一个漂亮数字更能说明你真做过。
自助解决率的口径必须说清,这是最容易被追问穿的地方。分母是什么(所有进入 Agent 的会话,还是排除掉一进来就点转人工的)、分子怎么算(没有转人工就算解决?用户主动结束就算解决?)。「用户没转人工」不等于「问题解决了」——他可能是放弃了。我们的口径是「未转人工且未在 24 小时内就同一问题再次咨询」,把二次咨询的排除掉。这个口径比通用口径严,数字会低一些,但它经得住追问。
评测集通过率:报绝对的通过用例数和总用例数,以及按类别拆开(正常 / 拒答 / 偏问法 / 诱导 / 历史事故)。历史事故那一类必须是全通过,这一类挂了就是回归,不允许发版。其他类别允许有一定失败率,因为里面有些用例本身就是难的边界情况。
拒答率要区分「主动拒答」和「兜底拒答」。前者是入口拦(检索不到),后者是出口校验拦下的。这两个数字的意义完全不同——主动拒答率高说明知识库覆盖不够,兜底拒答率高说明模型在编造。混在一起看就丢掉了诊断价值。
转人工率的基线要说清怎么来的。不能只报一个数字,要说明「这是在只放开查询类和政策类的前提下」。放开范围不同,这个数字完全不可比,脱离范围谈转人工率是没意义的。我们的做法是按咨询类型分别报。
灰度阶段的对比方法:同一时间窗内小流量组和对照组比,不用「改之前 vs 改之后」。因为咨询量和咨询类型有明显的时段和周期规律,前后对比会被这些因素污染。这是标准的 A/B 做法,但在提示词变更上常被忽略,因为大家不把改提示词当成一次发布。
没有匹配的内容,换个关键词试试。
项目拆解 · 智能客服 Agent 平台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据