多轮对话第二轮必现500:三个工程问题叠加,模型不该背锅 多轮对话第二轮就 5003 个工程问题叠加没有一层该由模型背锅最近排查了一个很典型的线上问题多轮对话系统在第二轮请求时必现 500报错还五花八门从网关超时到上游服务异常全都有。一开始业务方向的第一反应是模型是不是崩了但把链路拆开看锅根本不在模型那儿。这个案例特别适合拿出来聊因为它在工程上非常典型三个独立的小问题恰好叠加在一起单看哪一个都不至于翻车但串在一条链路上就变成第二轮请求必现故障。这篇文章我按排查顺序来写不直接甩结论。先把现象和日志还原出来再把三个叠加问题一层层拆开最后说说修复方案和验证手段。中间会穿插不少排查思路和判断依据希望看完之后再遇到类似的多轮对话 500你也能快速定位到到底是不是工程问题。1. 现象还原第二轮请求必现 500从日志里先读出大概先说复现条件。当时测试用的场景很简单用户先问第一句话系统正常返回紧接着用户再问第二句话请求打到网关大部分情况下直接就 500 了。偶发情况下能正常返回但概率极低大概十分之一都不到。这个第二轮必现、第一轮稳定的特征非常关键它直接把问题范围从模型能力缩小到状态与链路。我当时先做了三件事查网关访问日志、查应用侧异常堆栈、查模型网关返回的原始响应。查下来的结果很有意思——三个不同层面的日志给出了三种不同的报错。第一网关日志里记录的是 502 和 504 混合不是单纯的 500。有些请求是上游连接被重置有些是等待上游响应超时。第二应用服务日志里出现了两类异常一类是 Redis 连接池获取连接超时另一类是请求上游模型服务时 read timeout。第三模型服务本身的日志里偶尔能看到一条request length exceeds max context length的提示但更多时候模型侧根本没收到请求。这三条信息放在一起基本能形成一个初步判断问题不是模型推理炸了而是从应用侧到模型侧之间的链路在第二轮请求时出现了某种系统性故障。为什么第二轮才触发为什么模型侧有时候收不到请求我决定顺着请求生命周期逐层查。1.1 请求链路里三个最容易互相甩锅的层这套多轮对话系统的链路是这样客户端请求先进网关网关负责鉴权和路由然后转发到应用服务。应用服务把当前问题和历史消息拼装成上下文再去请求下游的模型服务。这里已经有三层了网关层、应用服务层、模型服务层。每一层都有自己独立的日志、独立的超时配置、独立的连接池。问题最麻烦的地方就在于当故障发生时每一层的日志都只记录自己看到的症状真正的原因可能藏在另外一层。比如网关看到的是上游超时但导致超时的原因可能是应用服务在等 Redis 连接而 Redis 连接池之所以耗尽又是因为某个连接泄漏了。这就像三个人接力跑第三个人摔倒了前面两个人都会说我正常交接了可实际上问题出在交接棒那一下。多轮对话的链路比单轮对话多了一个关键环节会话状态的读取与回写。第一轮请求时没有历史消息可读只需要把第一轮的结果存起来第二轮请求时需要先把第一轮的消息读出来拼进上下文再发给模型然后把第二轮结果追加存储。这个先读再写的过程恰恰是多轮对话最容易藏雷的地方。1.2 第一次抓包后发现的规律性错误我先不纠结代码层面的猜测直接抓包看网络层表现。用网关的单条请求追踪 ID 去关联应用服务和模型服务的日志发现一个规律第二轮请求的追踪 ID 能正常生成并传下去但应用服务往模型服务发起 HTTP 调用时大部分请求在连接建立阶段就失败了小部分请求发出去了但迟迟等不到响应。我当时也怀疑过是不是模型服务负载太高但看了一下模型服务所在集群的负载指标CPU 和内存都处于很低水位。这时候基本可以确定问题一定出在应用服务与模型服务之间或者应用服务内部的某个环节。接下来我决定分成两条线查第一条线是应用服务内部的状态管理逻辑第二条线是应用服务到模型服务的 HTTP 连接管理逻辑。结果两条线都查出了问题而且这两个问题还会互相加重最后把网关的超时配置一掺和就变成了用户看到的 500 全家桶。2. 叠加问题一会话上下文恢复在第二跳时断裂先看应用服务内部。多轮对话的前提是系统能记住用户之前说了什么这个记忆在工程上通常落在一个会话存储里常见做法是扔 Redis用会话 ID 作为 key 存历史消息列表。问题往往不会出现在第一轮因为第一轮没有历史可读但第二轮开始每一步都依赖这个存储的完整性。我在代码里找到了会话上下文的构造函数它做两件事从 Redis 取出当前会话的所有历史消息然后把这些消息拼接成模型要求的消息数组格式。听起来很常规对吧实际跑起来问题出在序列化格式上。2.1 会话 ID 生成与存储第一轮的没问题为什么是隐患第一轮请求进来时代码根据客户端传来的用户 ID 生成一个会话 ID然后用这个 ID 把用户的第一条消息和模型回复写成 JSON 数组存进 Redis。第一轮为什么没问题因为第一轮根本不会走读取分支——会话 ID 是刚生成的Redis 里没有旧数据直接新建一条记录写进去存完就返回了。问题隐藏在这个设计里会话 ID 的生成规则依赖用户 ID 会话序号而会话序号是从客户端请求头里读出来的。如果客户端第二次请求时没有带上会话序号或者带上了一个新的序号服务端生成的会话 ID 就和第一轮不一样Redis 里自然读不到任何历史消息。但你可能会问如果读不到历史消息顶多是模型不知道上文为什么会 500关键在于代码逻辑对这些读不到的情况处理得很粗暴——它直接抛了一个异常异常一路往上抛到网关网关统一转换成了 500 响应。说白了这是一个典型的逻辑假设错误开发时假设只要进入第二轮必定能读到历史消息可实际调用方的请求头参数不稳定导致这个假设在部分请求上不成立。这就是第一个叠加问题会话上下文恢复失败直接把请求打挂。2.2 第二轮的读取链路序列化、字段丢失与 Key 过期就算客户端每次请求都老老实实带了会话序号第二次请求真的能读到 Redis 里的数据吗我继续往下查又发现一个隐藏问题。第一轮存储时历史消息被序列化成了 JSON 字符串字段结构是{role:user,content:第一条消息,timestamp:123456}这样的形式。第二轮读取时代码用了一个 JSON 反序列化工具把它转成内部对象然后从对象里取role和content两个字段。表面上没问题但真实数据里多了一个timestamp字段这个字段在某些情况下会被服务端反序列化配置忽略掉。忽略就忽略吧可问题在于内部对象在拼装消息数组时会顺手校验字段完整性——它发现timestamp缺失就直接判定这条消息非法拒绝拼装并抛出异常。这个问题的根子在于第一轮存储的数据结构和第二轮读取的数据结构定义不同步。第一轮写入用的序列化类包含三个字段第二轮读取用的反序列化类只认两个字段工具类放宽容错后又把它当成一个问题来报错。多轮对话项目里这种字段不一致很常见因为定义会话消息结构的人和定义反序列化对象的人往往不是同一个时间段在写代码。还有第三个坑Redis key 过期时间。当时给会话存储设置的过期时间是 15 分钟。如果用户第一轮和第二轮之间间隔超过 15 分钟Redis 里早就没有这条 key 了读取同样会失败。这个其实不能算 bug算是配置策略问题但它的错误表现和上面的序列化问题一模一样排查时会让人非常困惑。后来我在异常处理里加了日志打点把读不到 key反序列化失败字段校验失败区分开才逐个定位出来。到这里第一个叠加问题已经确认会话上下文恢复链路存在多个失败点每个失败点都会让第二轮请求直接中断表现成 500 或网关层超时。3. 叠加问题二历史消息重放导致的 Token 预算溢出会话上下文恢复的坑填完一部分之后我继续跑第二轮请求测试发现另一个问题浮出水面有些情况下上下文能正常读出来了但应用服务往模型服务发请求时模型服务那边返回了一个错误。这个错误在业务日志里被简化成了上游返回非 200 状态码最终也变成了用户视角的 500。细化日志后发现模型服务返回的状态码是 400错误信息是上下文长度超限。这个问题的触发链条比较隐蔽。3.1 为什么第二轮会比第一轮多消耗这么多 Token先解释一下多轮对话的 Token 消耗逻辑。第一轮请求上下文里只有一条用户消息Token 数全部花在那一条消息上。第二轮请求上下文里要带上第一轮的用户问题和第一轮的模型回答再加上第二轮的用户问题Token 总量大约是第一轮的 3 倍左右。问题出在消息拼接策略上。当时为了让模型更好地理解对话场景应用服务在每次请求时会把全部历史消息都带进上下文。如果对话一直不结束历史消息会越积越多Token 消耗会线性增长。看起来这个设计也没问题很多多轮对话系统都是这样做的。但它忽略了一个关键参数模型服务允许的单请求最大 Token 数。当时配置的上下文上限是 4096 个 Token第一轮请求只用了 200 多个 Token很安全第二轮按理说也就 600 多个 Token怎么算都不该超。3.2 超出预算后的行为不是模型拒绝回答而是请求直接被拒我继续挖,发现超出预算的判定方式和想象中不太一样。应用服务在拼装请求时会对消息数组做一次 Token 预估预估结果超过模型上限的 80% 时它不会发起请求而是先做截断——问题就出在这个截断逻辑上。截断逻辑的本意是把最旧的几条历史消息从中间裁掉保留最近的消息。但代码实现里有一个线程安全问题这个Token 预估过程依赖一个共享的计数器对象在多线程环境下预估和截断不是原子操作导致第二轮请求在并发场景下可能拿到一个错误的上文长度。具体表现就是请求 A 和请求 B 同时在拼装第二轮上下文A 先做预估发现刚刚好没超上限B 随后做预估却因为共享计数器已经被 A 修改过误判成超限于是 B 触发截断逻辑。截断逻辑又把消息数组处理坏了构造出一个不完整、甚至重复包含同一条消息的上下文发到模型服务后被模型服务判断为请求非法。这已经是第二个叠加问题Token 预估的线程安全和截断逻辑的边界条件处理不当让一部分第二轮请求在模型还没真正开始推理之前就被判定为非法。严格来说这也不是模型的问题模型只是按照预设的上下文长度上限拒绝了非法请求它没有权利也没有义务帮你处理工程侧的上下文拼装错误。这个问题的排查难点在于它不是每次必现而是压力上来之后概率出现。单发测试第二轮请求往往没问题但压测一开并发数上去问题就频繁冒头。排查时不要只盯着上下文超限这个结果还要去想清楚为什么同一个上下文单跑不超并发就超。4. 叠加问题三网关超时与连接复用的隐形冲突前两个问题修完我已经能连续跑很多轮对话不带失败但还没完。在并发压测到一定阈值时网关层依然会出现 500 和 504。这第三个问题就藏在应用服务到模型服务的 HTTP 客户端的连接管理里。这个问题的表现很有意思单请求、低并发下完全正常一旦 QPS 抬上来失败率跟着涨而且失败模式是连接重置和读超时混着来。我一度以为是模型服务撑不住后来查了模型服务侧的网络和负载指标依然健康得很于是把目光转到应用服务的 HTTP 连接池配置上。4.1 超时时间的乐观估计看一眼当时的连接配置connectTimeout设的是 3 秒readTimeout设的是 30 秒maxConnections设的是 50maxIdleTime设的是 5 秒。这个配置的问题在于readTimeout的 30 秒长的超出预期叠加前面两个问题导致的异常重试逻辑出现了非常尴尬的连锁反应。具体场景是这样的第二轮请求走到应用服务从 Redis 读历史消息正好遇到 Redis 连接池短暂抖动等待连接花了 4 秒。这时应用服务并没有直接放弃它认为还在合理等待时间内继续往下执行。然而网关侧配置的请求超时是 10 秒应用服务内部花掉 4 秒后加上消息拼装、序列化等耗时走到真正向模型服务发起 HTTP 调用时已经过了 9 秒多。然后应用服务向模型服务建立连接connectTimeout 3 秒偏偏模型服务当时的网络栈正在处理大量新建连接握手慢了一拍。结果就是请求还没完全发出去呢网关那边 10 秒超时已到先报了 504。应用服务端压根没收到任何响应它自己也等了一个 connectTimeout最后抛出一个连接超时异常。如果只到这里还不算复杂。更麻烦的是重试逻辑。应用服务里对模型调用做了一次重试重试条件是连接异常。第一次连接超时后它立刻发起第二次请求这时候网关已经超时报错但应用服务的执行流程还在继续。第二次请求顺利发到了模型服务模型服务也正常返回了结果。可问题是这个响应已经没有任何客户端在等了——网关在 10 秒超时的那一刻就把请求掐断了把错误返回给了用户。4.2 连接池复用与半关闭连接的第二轮必现问题还有个连接池层面的问题。HTTP 客户端连接池里有一个空闲连接回收机制默认 5 秒回收一次空闲超过阈值的连接。但第一轮的响应还没被完整读取完连接就被放回了池子第二轮的请求又恰好复用了这个脏连接导致写入请求时数据被截断或追加到了前一次响应的尾部字节后面。这就解释了为什么第二轮请求在连接层面的失败概率特别高第一轮请求结束后连接被放回连接池但它的读缓冲没有完全清空第二轮请求复用了同一条连接写入时触发对端解析错位模型服务直接断开连接。应用服务只见连接被远端重置于是抛出 ConnectException。这个 bug 的经典之处在于它只在连接复用的时候出现而连接复用又只在持续有流量时出现。冷启动后第一次、第二次请求每条请求各自新建连接完全正常一旦流量稳定连接池开始复用连接第二轮请求就很容易踩到脏连接。排查手段非常明确在应用服务侧打印连接池中每个连接的使用状态、最后请求结束后有没有把输入流读完就能搜到那些response body 还没读干净的连接。到这里三个问题叠加的全貌已经浮现会话状态恢复失败导致部分第二轮请求直接中断Token 预估与截断的线程安全缺陷导致部分上下文非法连接池复用与网关超时配置冲突导致慢性超时与连接重置。三个问题互相掩盖、互相加重最终呈现给用户的就是第二轮必现 500。5. 为什么这三层都不该模型背锅很多团队一看到多轮对话请求失败第一反应就是模型服务是不是能力不行了模型是不是被问糊涂了。但这个案例里模型服务一次模型推理错误都没有所有失败点都发生在请求尚未真正进入模型推理阶段或响应返回阶段。模型背锅的常见误解需要一个澄清如果模型真的出错它返回的通常是 200 状态码但内容异常比如答非所问、语义偏离、回复截断、重复语句等这些属于模型侧问题。而 500、502、504 这类状态码语义上全部是服务端或网关问题模型压根没有参与这个过程。5.1 模型返回的真实错误类型与 500 的边界为了把这件事说得更清楚我把整个请求生命周期里可能出现的错误画了一遍。用户请求到达网关网关予以鉴权和转发应用服务开始工作包括读取会话上下文、拼装消息、预估 Token、调用模型服务模型服务接收请求经过内部排队、上下文处理、推理、输出流式返回。在这条链路上模型服务只有在真正推理那一步出错时才可能把错误反馈给上游。比如输入内容包含非法格式、单次请求超限模型服务会返回 400 或类似状态码推理过程中资源耗尽模型服务可能返回 500推理正常但响应流中断模型服务返回 200 加一个不完整的流。结合这个案例我们遇到的 400上下文超限其实是应用服务拼装的结果已经非法了模型服务只是做了它该做的校验工作拒绝了非法输入错误源头在应用服务侧。遇到需要连接超时报错连接在建立阶段就失败模型服务连请求都没收到锅当然不在模型服务。所以我的结论很简单出现 500 类的状态码优先怀疑网关、路由、鉴权、服务间调用、超时配置、连接池管理、状态存储这些工程环节。模型侧可以在模型响应明显不符合输入语义时再去怀疑。这是这次排查给我的最大启发不要让模型变成一个筐什么都往里装。5.2 工程问题与模型问题的判别清单我整理了一份实用判别清单后面再遇到类似场景可以直接对照。排查顺序如下先拿到网关状态码和应用错误码500 以上直接跳到工程侧200 但有内容异常才考虑模型侧。多轮对话请求失败时先确认会话上下文读取是否成功、场景是否精确打印了历史消息数量和字段完整度这一步能筛掉一半状态类问题。用请求追踪 ID 把所有日志串起来重点比较第一轮和第二轮日志的关键差异字段。比如第二轮多了历史消息读取上下文拼接Token 预估这几个过程排查重点就放在这几步。连接超时类错误检查连接池配置、空闲回收策略和代理层超时时间通常都是这里的配比出了问题。如果只有并发时才出现就放宽到线程安全与共享对象、信号量、计数器这些领域多半是并发访问没有做好隔离。按照这个顺序排查至少能保证不会一上来就往模型身上扣帽子少走很多弯路。6. 修复落地方案与压测验证问题定位清楚之后修复其实不难难的是保证修复之间互相不干扰。我按优先级和依赖顺序做了三处改动每一处都配了独立的验证。6.1 三个修复点的最小改动第一个改动点是会话状态恢复链路。我做了三件事给 Redis 会话存储的操作全部加上 try-and-check 分支任何读取失败都有明确的异常类型和日志输出做了一次序列化字段的彻底对齐把写入和读取的类合并成同一个定义会话过期时间从 15 分钟调整为 30 分钟并增加了刷新机制每次读写都会刷新过期时间。这个改动解决的是第二轮请求一进来就挂的直接原因。改完之后单跑第二轮请求成功率立刻回升到 100%。第二个改动点是 Token 预估与截断逻辑。我把共享计数器改成每请求独立构建的上下文预算对象消除了线程间的共享状态依赖。同时给截断逻辑加了一个防御性检查截断后的上下文必须重新校验角色交替合法、消息数量至少为 2、预估 Token 必须小于模型上限否则直接报出配置错误而不是带着坏数据发出去。这个改动解决的是高并发下上下文拼装偶发非法的问题。改完之后单发和压测下的非法请求都归零了。第三个改动点是连接管理。我把应用服务到模型服务的 HTTP 连接池的 readTimeout 从 30 秒调整到 5 秒并在请求最终发出前检查一次网关剩余时限如果剩余不足直接快速失败而不是让网关白白等超时。同时在每次请求响应读取完毕后显式把输入流读完再释放连接回连接池。另外关闭了空闲连接复用设置 idle 超时后强制重新建连。这个改动解决的是网关超时与应用服务耗时不对齐造成的 502/504 混出现象同时解决了脏连接复用触发的第二次请求被重置问题。改完之后压测日志里再也看不到连接重置和读超时了。6.2 验证方法从单发测试到并发回归修复完三个点之后我做了一轮比较完整的验证分为四个阶段。第一阶段是单请求回归第一轮、第二轮、第三轮对话分别跑 50 次全部通过响应速度和 Token 消耗都在预期范围。第二阶段是多轮长会话测试让同一个会话连续对话 20 轮每次轮次的上下文读取和 Token 拼接都记录完整日志确认无异常。第三阶段是并发压测模拟 30 个用户同时发起第二轮请求持续压测 10 分钟观察应用服务日志、模型服务日志、网关日志三条链路的错误率。结果错误率为零连接池监控显示连接创建和回收都在合理范围。第四阶段是故障注入式回归手动把 Redis key 删掉、手动把上下文构造类改坏确认所有失败分支都能输出清晰日志不会再以网关 500 全家桶的形式模糊呈报。这套验证做完我对修复结果算是比较有底了。这次排错给我的最大收获不只是把三个问题找出来了而是建立了一种判断力多轮对话系统的稳定性真正的瓶颈往往在工程侧的状态管理与链路协调上模型侧反而最少出错。希望这篇文章的排查思路能帮大家在下次遇到类似 500 时少一点慌张、多一点方向。