首页 / 资讯中心 / 文章详情

多轮对话第二轮必现500:三个工程根因与排查复盘

多轮对话第二轮必现500:三个工程根因与排查复盘 ★ FEATURED ARTICLE
上周晚上 11 点同事在群里甩了一张截图多轮对话第二轮请求直接 500而且必现。第一轮好好的第二轮一点就炸用户已经在小规模试用群里讨论越来越激动。第一反应几乎所有人都一样——模型出问题了服务不稳定赶紧去查模型服务商的监控面板结果流量曲线平静得像什么都没发生过错误率为零。再去翻推理网关日志也没有明显的 5xx。但业务 API 网关的告警明明在响红得刺眼。我按捺住性子把完整链路拆开看最后发现这口锅从头到尾就没落到模型头上。准确说是三个工程问题叠在一起恰好把故障伪装成了“第二轮必现 500”。这篇文章就把当时的排查过程、三个根因和修复方案完整记录下来给所有做对话类应用、尤其是接了模型 API 做二次开发的同学一个参考以后再遇到“第二轮就挂”先别急着怀疑模型。1. 现象复盘为什么偏偏是第二轮先说架构。整个链路分三层业务 API 网关负责会话管理、上下文组装、推理网关负责统一转发模型请求、鉴权、超时控制、模型服务。第一轮请求用户只发一句话没有历史消息业务网关把系统提示词拼上就直接发给推理网关模型正常返回。第二轮业务网关要从会话存储里取出第一轮的用户消息和模型回复拼进上下文再发给推理网关结果这一发就 500。表面上看问题指向很明确第一轮正常、第二轮异常差别只在于“多了历史消息”那不就是历史消息把模型惹毛了很多人到这里就会下结论模型对多轮支持不好或者上下文格式传错了。但格式传错是工程问题不是模型问题——传错了是调用方没按协议来。我做的第一个测试很暴力手动把第二轮请求应该携带的完整消息列表截出来用脚本直接调模型服务完全正常返回结果 200内容质量也没问题。测试路径请求内容结果业务 API 完整链路自动组装的第二轮上下文500脚本直连模型服务手工构造的第二轮上下文200业务 API 只发第一轮无历史消息200推理网关转发手工上下文绕过业务网关直连推理网关200四组对照测完结论已经清楚了模型没毛病推理网关没毛病问题出在业务 API 网关——要么它组装的上下文内容有问题要么它在转发过程中没有正确对待上游的异常。顺着这个思路继续拆最终挖出了三个独立存在、叠加后才致命的工程问题。这里要先立一个判断原则凡是能通过直连模型复现的问题才可能和模型有关凡是直连模型一切正常、走完整链路才出的问题第一嫌疑永远是工程层不用怀疑模型至少不该第一个怀疑。2. 工程问题一上下文组装层把脏字段塞进了历史消息排查第一步是抓实际发出的请求。我在业务网关加了一个临时日志把第二轮组装出来的 messages 数组完整打出来问题立刻暴露历史消息里塞进了大量不该出现的东西。具体来说团队为了实现“流式打字机效果”第一轮请求走的是流式接口。开发同学为了省事把流式返回的原始增量对象直接累积成一个 message 对象存进了会话缓存。这个原始对象里除了 content 之外还带着finish_reason、接口返回的元数据字段甚至在部分模型接口里还带了一个reasoning_content字段——这是模型内部思考过程的文本正常使用中根本不该回传给模型。第二轮组历史时系统直接把缓存里的原始 message 对象塞进 messages 数组原封不动发给推理网关。推理网关再转发给模型服务时模型服务方的 schema 校验当场拒绝assistant 消息里带了未被允许的字段返回 400。但注意业务网关拿到这个 400 之后做了统一异常处理把 400 吞掉换成了一个通用 500 返回给前端。这一层错误吞没是第三个问题先按下不表但要记住它。2.1 为什么脏字段只在第二轮爆发第一轮没有历史消息所以不存在回放问题。第二轮要把第一轮的消息从缓存里读出来脏字段在这个环节进入请求。更坑的是本地自测很难发现开发自测时通常用简单文本模拟或者干脆不走流式接口缓存里存的是干净消息。只有线上联调、真实用户通过前端流式交互时原始对象才会带着完整字段落库。这种情况在对话类应用里太常见了。模型接口的 message 对象远比我们以为的复杂常见的字段还包括 tool_calls、function_call、refusal 等这些字段在“当前轮”作为模型输出是合法的但作为“历史 assistant 消息”回放时不同模型服务商的处理策略不一样严格的服务商直接 400宽松的会忽略但请求体体积已经被撑大了。2.2 上下文组装的正确姿势字段白名单清洗修复方案不复杂核心是缓存时只存需要回放的数据回放时只放协议允许的字段。提供一个清洗函数在组装历史消息时强制过滤# 只保留协议允许回放的字段 def clean_history_message(raw_message: dict) - dict: allowed_fields {role, content} cleaned { role: raw_message.get(role), content: raw_message.get(content, ), } # 如果历史里有工具调用记录要按模型要求的格式单独转写 if raw_message.get(tool_calls): cleaned[tool_calls] raw_message[tool_calls] return cleaned这里的核心思路是白名单思维不是黑名单。黑名单永远防不住模型服务商将来新增字段白名单只放确定合法的字段一劳永逸。同时在写入缓存那一步就做清洗不要让脏数据落地。我在项目里加了两个措施第一会话缓存落库时只保留 role、content 和按协议转写过的 tool_calls 字段其他一概丢弃。第二组装请求前再加一道白名单校验两次夹击彻底杜绝脏字段上位。2.3 快速自查怎么判断是不是这个原因一板一眼的排查方法很简单把第二轮的 messages 数组原样打印出来逐条看历史 assistant 消息的字段。如果发现 content 之外还有 reasoning_content、finish_reason、delta、parts 之类的字段基本就是它了。立即把缓存里的原始对象换成清洗后的字段故障大概率立刻消失。如果想做成自动化防线可以在请求日志里记录每次请求的字段结构一旦出现协议外的字段就报警。3. 工程问题二推理网关的超时与上下文压力双重夹击脏字段问题修掉之后第二轮 500 竟然还在只是概率从 100% 降到了 30% 左右。这说明还有另一个问题而且它不是必现的更像偶发。这次轮到看推理网关日志。翻到故障时间点发现一批请求在推理网关侧报了read timeout超时阈值居然是 10 秒。再往深挖看到两个细节连接池最大 10 个连接高峰期排队严重而且请求体里携带的消息量比预期大得多。3.1 上下文体积膨胀推理耗时被拉长别看第二轮只是多了一轮历史实际上上下文体积涨得惊人。我做了个粗略估算系统提示词约 500 字折合约 750 token第一轮用户输入约 10 个汉字模型回复约 1200 字折合约 1800 token。第二轮用户再输入 10 个字整个请求的输入 token 数大概是 2600 左右是原来的三倍还多。输入 token 暴涨直接影响模型服务的处理时间。Transformer 解码的时候每生成一个 token 都要对所有输入 token 做注意力计算输入翻三倍前处理阶段和每一步解码的计算量都同步上涨。实测下来第一轮请求 1.5 秒返回首 token第二轮涨到 3.5 秒总生成时间从 5 秒拉到了 9 秒。如果只是 9 秒10 秒超时还能勉强扛住但高峰期连接池排队 3 到 4 秒总耗时一下就突破 12 秒推理网关直接判定超时向上游返回 504。指标第一轮第二轮输入 token 估算约 800约 2600首 token 耗时约 1.5s约 3.5s总生成耗时约 5s约 9s高峰期排队时间0s约 3s网关读超时阈值10s10s是否触发超时否是3.2 脏字段对上下文体积的二次放大这里还有一个隐藏的放大器第一个工程问题留下的脏字段。reasoning_content这种思考过程字段往往特别长模型内部推理一遍这段内容可能有几百上千字。它被存进了缓存第二轮又被组装进请求等于白白给上下文加了上千 token。脏字段不只是格式问题还直接把延迟推过了超时临界点。两个问题叠加让偶发超时变成了大概率超时。解决超时问题不能只调大超时阈值那是在掩盖问题。我把超时改成了动态估算请求输入 token 量小就给短超时输入量大给长超时。估算方式可以很粗糙模型输入大概每秒能处理多少 token心里要有个底再乘一个 1.5 到 2 的余量系数def estimate_timeout(messages: list[dict], output_tokens: int 512) - int: # 粗略估算输入 token 数中文约 1.5 token/字英文约 0.8 token/词 total_chars sum(len(msg.get(content, )) for msg in messages) input_tokens int(total_chars * 1.5) # 假设解码速度约 30 token/s生成 output_tokens 需要的时间 decode_seconds output_tokens / 30 # 前处理时间输入越大越慢粗略按每千 token 增加 0.5s 估算 prefill_seconds 1.0 input_tokens / 1000 * 0.5 # 预留排队、网络、重试的余量 return int(prefill_seconds decode_seconds 5)同时把连接池从 10 个扩到 30 个加了对推理网关排队时长的监控队列深度超过阈值就提前告警。超时之后的重试逻辑也从“立刻重试一次”改成“随机抖动 1 到 3 秒后再重试”避免重试请求扎堆把已经过载的推理链路彻底打崩。4. 工程问题三错误处理层“一锅端”把上游真实信息全吞了前面两次修复之后按理说 500 应该彻底消失了。但我在做回归验证时故意构造了一个非法请求去测试发现业务 API 网关依然返回一个干干净净的 500{code: 500, message: internal error}。这个响应本身就是第三个工程问题——错误处理层的异常吞没。打开全局异常处理器看一眼代码大概是这样的app.exception_handler(Exception) async def global_exception_handler(request, exc): logger.error(global exception: %s, exc, exc_infoTrue) return JSONResponse(status_code500, content{code: 500, message: internal error})所有异常不管来自哪里全部被这个全局处理器兜住一律返回 500。上游模型服务返回的 400 被吞、推理网关返回的 504 被吞、参数校验错误被吞、甚至数据库连接超时也被吞。前端拿到的永远是标准 500排障人员看到 500 就默认是服务端出 bug然后一层一层查查半天发现真正的原因早被这层包装藏起来了。4.1 错误码透传的规范设计正确做法是分层设计错误信息对外只暴露安全、无歧义的错误描述对内完整保留上游状态码、内部错误码、trace_id方便排障。内部错误码建议分段设计一段标识错误来源一段标识具体场景。以我们项目为例设计成这样一张表错误码段来源示例100xx参数校验类10001 消息格式非法200xx会话存储类20003 会话上下文读取失败300xx推理网关类30001 上游请求超时400xx模型服务类40010 模型触发输入内容限制映射规则是上游返回 4xx内部记录完整上游响应对外返回 502 并带内部错误码上游返回 5xx内部标记为服务不可用对外返回 503推理网关超时统一识别为 30001。全局异常处理器只兜底真正的未知异常不再贪吃蛇一样吞掉所有错误。修复后的异常处理器长这样# 自定义业务异常携带内部错误码和上游状态码 class ApiException(Exception): def __init__(self, status_code: int, internal_code: int, message: str, upstream_status: int | None None): self.status_code status_code self.internal_code internal_code self.message message self.upstream_status upstream_status app.exception_handler(ApiException) async def handle_api_exception(request, exc: ApiException): return JSONResponse( status_codeexc.status_code, content{ code: exc.internal_code, message: exc.message, request_id: request.state.request_id, upstream_status: exc.upstream_status, }, )配合一个从请求开始就生成的 request_id也叫 trace_id前端报错时只要把 request_id 发过来后端一条命令就能把整条链路拉全。4.2 日志串联用 request_id 贯穿三层多轮对话这种多服务链路最怕各层日志自成体系。业务网关有业务网关的日志推理网关有推理网关的日志模型服务商有它的日志彼此之间没有关联字段排查时只能靠时间窗口盲猜。必须从入口生成 request_id通过 HTTP Header 传给每一个下游。业务网关和推理网关都是自研的传 header 很容易。模型服务商那边如果支持透传自定义 header就传过去不支持的话至少推理网关要记录 request_id 到模型服务请求的时间戳靠时间对齐。排查时的实际操作就是 grep# 按 request_id 拉全业务网关日志 grep request_id8f3a2b1c app_gateway.log # 按同一个 request_id 拉全推理网关日志 grep request_id8f3a2b1c inference_gateway.log # 如果模型服务商侧没有该字段用时间段入参摘要对齐 grep 8f3a2b1c model_side.log这一步在复盘时帮了大忙。修复错误透传之后再构造一个错误请求日志里直接显示上游返回了 400、具体是哪一段消息字段非法、模型服务商给出的校验提示是什么一眼定位不再需要翻聊天记录猜来猜去。5. 三层问题叠加后的排查路径与工程复盘回头看整个故障与其说是一个 500不如说是三层问题刚好踩在同一个点上互相放大的结果。单独看任何一个都不至于让故障表现成“第二轮必现 500”。第一层上下文组装脏字段负责让模型服务商拒绝请求但它返回的是 400如果没有第三层错误吞没前端会看到一个 400至少不会完全摸不着头脑。第二层推理网关超时是偶发的高峰期才会触发原本只会造成零星超时。第三层错误吞没把所有上游 4xx 和 5xx 全部统一成 500于是 400 的必现问题、504 的偶发问题在业务 API 层全部变成“第二轮必现 500”的单一现象。排查路径是倒着走的先看见 500确认在业务网关顺着业务网关抓实际发出请求发现脏字段修掉脏字段后必现问题变成偶发再看推理网关才发现超时最后为了彻底看清楚错误全貌修掉了错误吞没。如果一开始就打通了错误透传第一步就能从响应体里的upstream_status: 400直接锁定脏字段问题后面超时的排查也会更快。5.1 复盘后的关键结论别让模型背锅也别指望模型兜底这里要给团队立一个规矩任何对话类应用只要直连模型服务正常走业务链路异常必须先查工程链路这是排查顺序问题不是甩锅问题。反过来也成立如果工程链路不治理模型服务商确实会被误伤。用户看到的是整个产品他不会区分模型问题和网关问题只知道第二轮就 500 了。等到模型服务商把日志甩回来说请求根本没到或者格式非法那个场面才叫尴尬。我们的教训是多轮对话不是“把历史消息拼回去再发一次”那么简单。历史消息的存储格式、字段回放规则、组装后的体积控制、超时时间、错误码透传每一层都有工程边界需要守住。这些边界不是模型帮你守的模型只负责接收合法请求、返回结果或合法错误。5.2 上线前预防方案一览这次踩坑之后我把多轮对话类功能的检查项固定成了一张清单每次上线前过一遍检查项检查方法对应问题历史消息缓存字段白名单检查缓存结构确认只存 role/content/tool_calls防止脏字段回放消息 schema 实时校验组装请求前跑一道 JSON Schema 校验防止意外字段导致 400请求体体积监控记录每次请求的 token 估算值和实际字节数提前发现上下文膨胀推理网关超时动态化根据输入规模计算超时不设固定值防止高峰期 504错误码透传和 request_id检查响应体含 internal_code/upstream_status避免错误被吞没多轮回归压测至少跑 5 轮以上对话并发请求压推理网关覆盖必现和偶发问题最后再补一个建议会话缓存和上下文组装这两个环节一定要写单元测试和集成测试。测试用例里显式构造带脏字段的历史消息验证清洗逻辑生效同时验证超时重试逻辑不会导致消息重复发送。这些小投入比线上故障后再去补救便宜太多。我个人在实际排查里最深的一个体会是多轮对话 500 的故障绝大多数时候模型是无辜的。把排查顺序固定成“从外到内、从工程到模型”先看错误响应是否透传、再看请求内容是否合法最后才轮得到怀疑模型本身。大多数问题在第三步之前就已经水落石出了。
阅读完成 · 觉得有帮助?
咨询建站