第二次对话就500,我先把锅从模型身上摘下来
做对话类应用的朋友,可能都经历过这种时刻:第一轮人机对话一切正常,模型回复流畅得像真人在线;用户紧跟着发了第二条消息,后端直接甩出一个500,页面弹窗报错,对话中断。乍一看,大家都说“模型是不是脑子不行了”“多轮对话能力太差”。但我今天想聊的恰恰相反——这个500,十有八九和模型半毛钱关系都没有。
我最近处理的一个模拟项目X就踩了同样的坑。项目是某跨平台系统的多轮问答模块,技术栈不复杂,前端接一个对话服务,对话服务再转发给大模型接口。第一轮调通之后,所有人都觉得稳了。结果一进第二轮,接口必报500,而且是稳定复现,不是偶发。排查了整整一天,最后发现其实是三个工程问题叠在了一起,每一层单拎出来都不至于崩,叠在一起就成了“第二轮必挂”的奇观。
这篇文章我就把这几个问题拆开讲,顺便把排查思路和修复方案一起整理出来,给正在做多轮对话服务的朋友一个参考。尤其是刚接触大模型应用开发的同学,遇到这种问题别急着怪模型,先看看自己工程层的这几个关键位置。
1. 故障现象:第一轮正常,第二轮必挂,日志里只有一行“内部错误”
先描述一下现场。整个链路是前端发送用户消息,后端拿到消息后,从会话存储里取历史记录,然后组装成消息数组发给大模型,拿到模型回复后再存回历史记录,最后返回给前端。
第一轮运行的时候,历史记录是空的,后端直接拿用户输入去调模型,链路很短,一切顺利。到了第二轮,后端需要先做一次“读历史记录”的操作,再拼接新一轮输入,整体流程比第一轮多出几个环节。也就是从这个时候开始,500就来了。
1.1 第一轮与第二轮的关键差异
很多人在排查时习惯盯着模型返回值看,但第一轮和第二轮的本质差异不在模型侧,而在数据链路上。
- 第一轮:无历史可读 → 组装请求 → 调用模型 → 返回成功
- 第二轮:读历史记录 → 组装请求 → 调用模型 → 写回历史 → 返回成功
第二轮多的不只是“读”和“写”,还有额外的序列化、反序列化和数据校验。任何一个环节出错,都可能表现为接口500。我们的日志系统当时只记录了“internal error”这种话,最致命的是没有把具体的异常堆栈打出来,排查难度直接拉满。
1.2 为什么500而不是400或超时
这是另一个值得注意的细节。400表示客户端参数有问题,超时表示外部依赖响应慢,但500表示的是后端服务自己抛了未捕获的异常。我们在日志里看到的是统一封装的HTTP状态码500,底层原因是服务端代码在某个位置直接崩了,而不是模型接口拒绝请求。
顺着这个思路,我把排查范围锁定在后端服务自身,先把模型调用、网络问题这些外部因素放一边。这个判断很重要,不然很容易绕进“是不是模型上下文太长”“是不是提示词格式不对”这些方向里去。
2. 三个工程问题叠加:状态路由、消息组装、异常兜底
确定了是后端服务内部抛错之后,我开始逐层审查代码。越看越觉得这个500背后其实是三个工程问题叠在一起,缺一个都不至于炸。
2.1 问题一:会话标识在路由层被“洗掉”了
多轮对话必须依赖会话标识来串起上下文。我们当时的实现是:前端每次请求都会带一个会话ID,后端根据会话ID去存储里拉历史记录。
问题出在后端网关层。网关对请求做了一次解析和重放,在内部重新生成了一套新的请求体,会话ID在重放过程中被覆盖成了空值。于是,第二轮请求到达对话服务时,后端拿到的会话ID是空的,读历史记录时直接查不到数据。
正常情况下,查不到历史也不至于500。但我们的代码写的是“查到的历史赋值给一个强类型对象”,空值继续往后传,在下一步组装消息时就触发了空指针异常。
这个问题的本质是:会话标识没有建立“端到端链路”的强制透传。网关以为自己在做无害的规范化处理,实际上把业务依赖的关键字段丢掉了。
注意:多轮对话系统的会话ID,应该是从入口到存储层的全链路主键,任何中间层都不能擅自改写。最稳妥的办法是在网关层明确白名单字段,未在白名单里的字段一律不碰。
2.2 问题二:历史消息的组装格式不合法,模型接口直接拒绝
第二个问题藏得更深。就算会话ID正常,历史记录也能读到,组装出来的消息结构仍然有问题。
我们的对话服务为了支持多轮总结,在每轮请求里会拼接三部分内容:系统提示词、历史对话摘要、最新用户消息。历史对话摘要这一块的格式,我们当时是从数据库里取了原始JSON字符串直接插进请求体里的。
第一轮没有历史,不会触发这个问题。第二轮开始,历史摘要是一个字符串,包含了之前用户消息和模型回复的原始文本,里面可能有换行、引号,甚至还有用户粘贴的代码片段。直接拼进新的JSON请求体后,JSON结构被破坏,后端的HTTP客户端解析响应时直接报错。
更隐蔽的是,有的历史消息角色标识已经错乱,比如把多轮对话里所有消息都标记成了同一种角色,这会直接打破大模型接口对“对话消息必须交替出现”的预期。虽然多数模型对角色错乱还有一定容忍度,但一旦解析层做严格的格式校验,就会直接拒绝。
2.3 问题三:并发场景下线程池耗尽,新的请求根本没机会执行
第三个问题是在压测环境里暴露出来的。单轮单次调用没问题,但只要多几个用户同时对话,第二轮就大面积出现500。
原因是对话服务的模型调用使用了同步阻塞模式,并且连接池配置得过小。第一轮请求还好,因为只有一个模型调用;第二轮开始,部分请求会先生成一个“摘要请求”,再发起“主对话请求”,相当于一个用户在第一轮的基础上多占用了一个工作线程。并发一上来,线程池被打满,后面的请求连线程都分配不到,直接抛异常返回500。
这个问题的本质是容量预估没有把“多轮带来的额外调用”算进去。第一轮一个请求对应一次模型调用,第二轮则可能对应两到三次,整体线程占用直接翻倍。
提示:排查多轮对话性能问题,不要只看接口QPS,要看“模型调用次数”和“线程占用水位”。用户量不大不代表线程池安全,因为一次对话可能在短时间内发起多个模型调用。
3. 排查实录:从“猜模型”到“看链路”的三个关键动作
很多朋友遇到类似问题,第一反应是去调模型参数、换提示词模板,折腾半天毫无进展。我当时的做法是把排查重心从“模型表现”挪到“工程链路”上,三个动作搞定了定位。
3.1 动作一:打开全链路日志的“信息增益开关”
刚开始日志里只有“internal error”几个字,完全没法定位。我第一件事是给对话服务的请求入口和出口分别打印结构化日志,把会话ID、消息长度、历史记录条数、模型响应码全部记录下来。
一轮打下来,立刻看到了规律:所有500请求的会话ID都为空,所有成功请求的会话ID都正常。这就把问题一暴露了。日志是定位问题的第一道光,别嫌弃它啰嗦,关键时刻比什么监控都管用。
3.2 动作二:用最小复现用例绕过前端,直接压后端接口
前端页面把请求经过网关、鉴权、限流等多层逻辑,排查时不方便。我直接用调试工具构造了两条连续请求,绕过前端页面,直连对话服务:
- 第一条请求:正常会话ID,内容“你好”
- 第二条请求:同一会话ID,内容“具体说说”
然后用同样的两个请求,把会话ID改成空值,再发一遍。结果第二组请求必现500,第一组请求完全正常。这一步直接实锤了“会话路由”的问题。
3.3 动作三:抓取发给模型的原始请求体,格式化后逐行检查
定位到组装层后,我把发给模型接口的原始请求体完整记录下来,手动做了格式化。结果发现历史消息摘要里残留了未转义的双引号,还把一段用户输入直接裸露在JSON的字符串值外面。
这一步非常有价值,因为如果不看原始报文,你永远不知道拼出来的JSON长什么样。很多“模型理解不了上下文”的问题,其实是“模型收到的内容根本不是一段结构合法的文本”。
4. 修复方案与落地细节:三层分别处理,一个都不能漏
找到三个问题之后,修起来其实不复杂,但必须一层一层改,不能只修其中一个。一旦漏掉任何一个,第二轮还是会以一种微妙的方式出问题。
4.1 状态路由层:会话ID强制透传与空值熔断
先修网关层的会话ID改写问题。我把网关的处理逻辑改成了“未知字段一律保留”,而不是“已知字段才透传”。同时在对话服务入口加了一个判断:如果会话ID为空,直接返回明确错误码,而不是继续往下执行。
这个“空值熔断”看着简单,实际价值很大。它让异常在最早的地方暴露出来,而不是让空值穿透多层逻辑后在某个深层函数里触发空指针,导致日志定位困难。
代码层面大致是这样的思路:
public ChatResponse chat(ChatRequest request) { if (StringUtils.isBlank(request.getSessionId())) { throw new BizException("session.id.missing", "会话ID不能为空"); } // 后续正常业务逻辑 }这样改动之后,凡是会话ID异常的请求,都会收到一个明确的业务错误码,前端能直接提示用户重新开始对话,而不是看到一大片空白。后端日志里也能直接按错误码聚合排查。
4.2 消息组装层:统一结构、严格转义、角色校验
历史消息拼接的问题,根本解法是不再手工拼接JSON字符串,而是改用结构化的对象模型,序列化交给JSON库处理。
我将历史摘要和系统提示词拆到独立的数据结构里,组装消息时统一走一套“消息对象数组”,每一条消息都包含明确的角色字段和内容字段。序列化时让JSON库自动处理转义,杜绝手拼字符串带来的结构破坏。
角色校验方面,我在发送前加了一个检查方法,遍历所有消息,确认角色在系统、用户、助手三种取值之间,且没有连续重复的用户消息。一旦发现角色错乱,就按顺序重排或丢弃异常消息。
def validate_messages(messages): last_role = None for msg in messages: if msg["role"] not in {"system", "user", "assistant"}: raise InvalidMessage(msg) if msg["role"] == last_role == "user": raise InvalidMessage("连续出现两条用户消息") last_role = msg["role"] return True这一步改完之后,发给模型的请求体终于是一段“干净、合法、有序”的对话记录,模型侧解析成功的概率大幅提升。
4.3 并发兜底层:改异步调用、动态扩线程池、增强异常分类
线程池耗尽问题的修法分两层。第一层是把模型调用从完全同步的模式改成带超时控制的异步模式,减少单请求占用的阻塞时间。第二层是把线程池参数从固定值改成基于实际模型调用次数的动态估算,同时为模型调用单独隔离一个线程池,避免对话服务里其他业务逻辑和模型调用互相抢占。
异常分类这块也做了细化。之前所有异常都统一抛成500,现在改成:模型接口返回超时算“可重试异常”,网络连接中断算“基础设施异常”,请求体数据格式错误算“业务参数异常”。三类异常分别走不同的兜底逻辑,超时的可以自动重试一次,基础设施异常直接短路熔断,业务参数异常则原样返回给调用方,方便快速定位。
try { String response = modelClient.chat(messages); return parseResponse(response); } catch (TimeoutException e) { // 第一次重试,并递增退避时间 return retryOnce(messages); } catch (ConnectionResetException e) { // 直接告警,进入熔断窗口 circuitBreaker.trip(); throw new GatewayException("模型服务暂时不可用"); } catch (IllegalArgumentException e) { // 这是组装层的问题,抛给上层暴露详情 throw new BadRequestException("消息格式异常", e); }注意:异步化不是万能的,它会把问题从“线程池耗尽”转移到“队列积压”。一定要给异步任务配好队列上限,超出上限要直接拒绝新请求,而不是无限排队。
5. 盘点常见问题:多轮对话第二轮500的排查检查清单
修完这个项目之后,我把类似的排查经验整理成了一张检查清单。之后再遇到同类问题,我都是先过一遍清单,基本能在半小时内定位。
| 检查项 | 检查方式 | 常见结果 |
|---|---|---|
| 会话ID是否完整透传 | 网关入口和对话服务入口分别打印会话ID | 网关在重放请求时覆盖了会话ID |
| 历史记录是否为空导致空指针 | 在读取历史后立刻判空,空则结束流程 | 空记录继续流转,触发深层NPE |
| 消息结构是否合法JSON | 记录原始请求体并格式化 | 手拼JSON字符串,引号未转义 |
| 消息角色是否交替正确 | 遍历消息数组做角色校验 | 连续两条同类角色,模型解析异常 |
| 模型调用是否超时未处理 | 给模型调用加显式超时异常捕获 | 超时异常未捕获,冒泡到顶层变500 |
| 线程池容量是否覆盖峰值调用 | 监控线程池活跃线程数与模型调用次数 | 线程池满,新请求被拒 |
| 历史消息和新增请求是否拼接正确 | 打印组装后的消息列表与角色顺序 | 新增消息被拼到了无关角色的后面 |
这张清单同样适用于其他类型的对话系统。只要你的链路里有状态存储和消息组装两层逻辑,就可能碰到表中列出的问题。排查的关键是“从数据流的角度去审视”,而不是把模型接口当成唯一变量。
6. 聊聊我的体会:工程链路没理顺,别急着给模型“定罪”
这次排查给我留下的最大体会是:多轮对话功能的稳定性,七成取决于工程链路,三成才取决于模型能力。模型确实有上下文长度限制、有理解偏差,但“第二轮必500”这类问题,几乎不可能由模型本身引发。模型顶多是“拒绝回答”或“答非所问”,它不会让后端进程直接抛异常。
如果你手头的对话系统也出现类似问题,我的建议是先从这四件事入手:
一是看日志,找到异常堆栈的真实位置,别只盯着外层状态码。
二是看链路,把网关、对话服务、模型服务之间的数据流画清楚,确认每个字段都按预期传递。
三是看报文,把发送给模型的原始请求体完整打印出来,格式化检查,很多诡异问题一眼就能看出来。
四是看容量,算清楚一个用户一次对话会触发多少次模型调用,用峰值数据去评估线程池和超时时间,而不是用平均值。
最后再分享一个小技巧:排查这类问题时,可以在对话服务的入口和出口各加一行日志,把收到的请求和返回的结果完整输出。虽然日志量会增加,但在出问题的那个窗口期,这行日志就是救命稻草。等系统稳定了再按需降级,不迟。
我在实际项目中已经用这套方法快速定位过好几次多轮对话的异常,每次都发现是工程侧的问题在捣乱。模型是无辜的,但前提是你的工程链路足够结实。