1. 问题现象与排查思路拆解1.1 一个让人抓狂的线上现象多轮对话系统上线后单轮问答一切正常但只要用户追问第二句接口直接返回 500。日志里堆着超时、序列化失败、上下文超长三种报错看起来像是模型服务崩了但把模型单独拎出来压测又完全正常。这种“第一轮岁月静好第二轮直接爆炸”的现象在对话系统里其实非常典型。我前后排查过不下十次类似问题结论几乎一致500 不是模型算不出来而是工程链路上有三层各自埋了雷第二轮恰好把三层雷同时踩响。这三层分别是会话状态管理、上下文拼装策略、以及请求生命周期的资源回收。任何一层单独出问题可能只是偶发报错三层叠加第二轮必挂。这篇文章就把这三层问题一层层剥开讲清楚每一层为什么会炸、怎么定位、怎么修以及修完之后怎么验证。适合正在做多轮对话系统、Agent 编排、或者任何需要维护会话上下文的开发者参考。哪怕你用的是自研框架还是开源方案排查思路是通用的。1.2 为什么偏偏是第二轮很多人第一反应是“模型上下文长度不够”但仔细想想如果只是长度问题第一轮就应该报错。第二轮才炸说明问题出在“状态从第一轮传递到第二轮”这个动作上。我习惯用一个类比来理解第一轮请求像是一次性纸杯用完就扔谁都不会在意第二轮请求像是一个需要重复使用的保温杯你得先把第一轮的水倒进去再加热水。问题就出在“倒水”这个动作——杯子可能漏、水可能太满、加热可能把杯子烧了。对应到工程上就是三件事会话状态存了但读不出来第一轮写入的上下文第二轮读取时格式对不上或者已经过期。上下文拼装没有做预算控制第一轮的回答被无脑塞进第二轮 prompt加上系统提示词和历史记录直接超出模型窗口。连接池或线程池被第一轮占满没释放第二轮请求拿不到资源超时后抛 500。这三件事单独看都不致命但它们在第二轮同时发生就形成了“叠加故障”。下面逐个拆。1.3 排查顺序先隔离再定位遇到这种问题最忌讳一上来就改模型参数。我的习惯是先把链路切成三段入口层、编排层、模型层。入口层负责收请求和鉴权编排层负责拼上下文和调模型模型层只负责推理。隔离方法很简单在编排层打一个开关强制第二轮请求使用“空上下文”调用模型。如果空上下文能通说明模型层没问题问题在编排层或入口层如果空上下文也挂那才是模型层的事。实测下来九成以上的“第二轮 500”都是编排层的问题。定位到编排层之后再按“状态读取 → 上下文拼装 → 资源释放”的顺序逐段加日志。不要一次性加太多日志否则刷屏反而看不清。我一般只在三个位置打点读取会话状态后、拼装完 prompt 后、调用模型前。这三个点的数据一对比问题基本就现形了。2. 三层工程问题的核心细节解析2.1 第一层会话状态的读写不一致会话状态管理最常见的坑是“写进去的是对象读出来的是字符串”。第一轮请求结束时代码把对话历史以对象形式存进缓存但序列化方式没统一——有的地方用 JSON有的地方用 pickle有的地方直接 str()。第二轮读取时反序列化失败抛异常被上层捕获成 500。还有一种更隐蔽的情况状态存了但 key 的生成规则不一致。第一轮用session_id user_id做 key第二轮用session_id timestamp做 key结果读了个寂寞。这种问题在日志里往往只显示“context not found”然后代码里一个context[history]直接 KeyError。排查这类问题我建议在状态读写的地方加一个校验写入时打印 key 和序列化后的长度读取时打印 key 和反序列化后的类型。两边一对立刻就能发现是 key 对不上还是格式对不上。注意不要用内存字典存会话状态。单机开发时没问题一上多实例就完蛋。第二轮请求可能被负载均衡打到另一台机器上内存里根本没有第一轮的数据。必须用外部存储比如 Redis 或数据库。2.2 第二层上下文拼装的预算失控上下文拼装是第二轮 500 的头号嫌疑犯。第一轮通常只有系统提示词加用户问题长度可控第二轮要把第一轮的问题、第一轮的回答、第二轮的问题全部拼进去长度直接翻倍甚至翻三倍。如果代码里没有做 token 预算只是简单地把历史记录 append 到 messages 列表里那么当历史记录稍微长一点就会超出模型的最大上下文窗口。不同模型的窗口大小不一样有的 4K有的 8K有的 32K。如果你在代码里写死了按字符数截断而模型按 token 计费字符和 token 的比例在不同语言下差异很大中文一个字符可能对应 1 到 2 个 token英文一个单词可能对应 1 到 3 个 token。按字符截断很容易算错。更麻烦的是有些模型在超出窗口时不会返回明确的“超长”错误而是直接超时或者返回空结果上层代码看到空结果就抛 500。这就导致日志里看到的是“模型调用失败”而不是“上下文超长”排查方向容易被带偏。我的做法是在拼装完 prompt 后、调用模型前加一个 token 计数步骤。用对应模型的 tokenizer 算一下总长度如果超过窗口的 80%就触发截断策略。截断策略优先级是先丢最老的历史轮次再丢系统提示词里的非关键部分最后才考虑压缩当前问题。千万不要反过来把当前问题截了那用户会直接骂人。2.3 第三层请求生命周期的资源泄漏第三层问题最容易被忽略因为它不直接报错而是让系统越来越慢直到第二轮请求拿不到资源。典型场景是 HTTP 连接池或者数据库连接池被第一轮请求占着没释放。比如第一轮请求调用模型服务时创建了一个长连接但代码里没有在 finally 块里关闭。第一轮请求结束后连接回到池子里但状态是“占用中”。第二轮请求来的时候池子里没有可用连接请求排队等待等到超时抛 500。日志里看到的是“connection timeout”但根因是第一轮没释放。还有一种情况是线程池泄漏。第一轮请求里开了一个异步任务去写日志或更新状态但没有 await 或者没有 join导致线程一直挂着。第二轮请求进来时线程池已满直接拒绝。排查资源泄漏我一般用两个手段一是看连接池的活跃连接数监控如果第一轮结束后活跃数没降回去基本就是泄漏二是在代码里加一个请求结束的钩子打印当前线程名和连接池状态。如果第二轮请求的线程名和第一轮一样说明线程没释放。提示资源释放一定要放在 finally 块里不要放在 try 块末尾。因为一旦中间抛异常try 块末尾的代码不会执行资源就泄漏了。这是最基础的工程规范但线上出问题往往就是这种基础规范没守住。3. 实操过程与核心环节实现3.1 会话状态存储的标准化改造先解决第一层。我选 Redis 作为会话状态存储因为它的读写性能好支持过期时间适合对话场景。关键是要统一序列化协议。import json import redis r redis.Redis(hostlocalhost, port6379, db0) def save_session(session_id, history): key fsession:{session_id} value json.dumps(history, ensure_asciiFalse) r.setex(key, 1800, value) # 30分钟过期 def load_session(session_id): key fsession:{session_id} value r.get(key) if value is None: return [] return json.loads(value.decode(utf-8))这段代码看起来简单但有几个细节值得说。第一ensure_asciiFalse保证中文不被转义成\uXXXX否则存储体积会膨胀三倍。第二setex设置了 30 分钟过期避免无效会话长期占用内存。第三读取时如果 key 不存在返回空列表而不是 None这样上层拼装上下文时不会因为 None 而报错。改造完之后在第二轮请求入口处加一行日志打印session_id和读取到的历史轮次数。如果历史轮次数是 0说明状态没存进去或者 key 对不上如果是 1 以上说明状态读取正常问题在后面的拼装环节。3.2 上下文拼装的 token 预算控制第二层的核心是“先算账再花钱”。我用的方案是在拼装完 messages 之后调用 tokenizer 算总长度。import tiktoken def count_tokens(messages, modelgpt-3.5-turbo): encoding tiktoken.encoding_for_model(model) total 0 for msg in messages: total len(encoding.encode(msg[content])) total 4 # 每条消息的角色和分隔符开销 return total def build_messages(system_prompt, history, user_input, max_tokens4096): messages [{role: system, content: system_prompt}] # 从最新历史往前加保证最近的对话优先保留 for turn in reversed(history): messages.insert(1, {role: user, content: turn[user]}) messages.insert(2, {role: assistant, content: turn[assistant]}) if count_tokens(messages) max_tokens * 0.8: # 超出预算移除最老的一轮 messages.pop(1) messages.pop(1) break messages.append({role: user, content: user_input}) return messages这里的关键参数是max_tokens * 0.8留 20% 的余量给模型生成回答。如果模型窗口是 4096那么输入控制在 3276 以内剩下的 820 给输出。这个比例不是拍脑袋定的而是根据经验大多数回答在 200 到 500 token 之间留 800 左右比较稳妥。另外count_tokens里每条消息加 4 个 token 是 OpenAI 格式的固定开销不同框架可能不一样需要根据实际使用的模型调整。如果你用的是国产模型最好查一下官方文档里的 token 计算方式或者直接用官方提供的 tokenizer。注意不要用字符数除以 2 来估算 token 数。中文、英文、代码、表情符号的 token 密度完全不同。我见过一个项目用len(text) / 2估算结果中文场景下实际 token 是估算值的 1.8 倍第二轮直接超长。3.3 资源池的监控与释放第三层的改造分两步一是确保释放二是加监控。释放的代码模式很简单但必须严格执行import requests def call_model(payload): session requests.Session() try: response session.post( http://model-service/v1/chat, jsonpayload, timeout30 ) response.raise_for_status() return response.json() finally: session.close()finally块里的session.close()是必须的。如果不关连接会一直挂在池子里。虽然 Python 的 requests 库有垃圾回收但回收时机不确定高并发下很容易在回收之前就把池子占满。监控方面我在请求入口和出口各打一个点记录当前活跃请求数和连接池状态。如果出口的活跃数没有降回去就告警。这个监控不需要很复杂一个简单的计数器就够用。import threading active_requests 0 lock threading.Lock() def handle_request(session_id, user_input): global active_requests with lock: active_requests 1 try: # 业务逻辑 pass finally: with lock: active_requests - 1这个计数器可以暴露给监控系统画成曲线。正常情况下曲线应该是锯齿状的有升有降。如果发现曲线只升不降说明有请求卡住了需要去查那个请求的堆栈。3.4 三层修复后的联调验证三层都改完之后不能直接上线要做一轮完整的联调验证。我一般按下面的顺序来单轮验证先确保第一轮正常返回结果和之前一致。双轮验证手动构造第二轮请求检查会话状态是否正确读取、上下文长度是否在预算内、连接是否正常释放。多轮验证连续发 5 到 10 轮观察每轮的长度增长和响应时间。正常情况下响应时间应该保持稳定不会因为轮次增加而线性增长。并发验证用 10 到 20 个并发用户同时发多轮请求观察是否有 500。这一步最容易暴露资源泄漏问题。异常验证手动在第二轮请求中注入超长输入看截断策略是否生效是否返回友好提示而不是 500。联调通过后再上灰度环境跑一天。灰度期间重点看三个指标500 率、平均响应时间、连接池活跃数。如果 500 率降到 0.1% 以下响应时间稳定连接池活跃数正常波动就可以全量了。4. 常见问题与排查技巧实录4.1 第二轮 500 的常见原因速查表现象可能原因排查方法修复方案日志显示 context not found会话 key 不一致或已过期对比第一轮写入 key 和第二轮读取 key统一 key 生成规则延长过期时间日志显示 token limit exceeded上下文拼装未做预算打印拼装后的 token 数加入 token 计数和截断逻辑日志显示 connection timeout连接池泄漏查看连接池活跃数监控在 finally 中关闭连接日志显示 KeyError: history反序列化失败或字段缺失打印读取到的原始数据统一序列化协议加默认值日志显示 thread pool rejected线程池满查看线程池队列长度检查异步任务是否未释放无明确报错直接 500模型返回空结果被上层抛异常打印模型原始响应加空结果判断和降级逻辑这张表是我从多次排查中总结出来的覆盖了九成以上的场景。遇到问题先查表能省很多时间。4.2 三个容易踩的坑第一个坑用内存字典存会话状态。开发环境单实例没问题一上生产多实例就完蛋。第二轮请求被负载均衡打到另一台机器内存里没有第一轮的数据直接 KeyError。这个坑我踩过两次后来强制规定所有会话状态必须走外部存储。第二个坑按字符数截断上下文。中文和英文的 token 密度差异很大按字符截断在中文场景下会严重低估 token 数。我见过一个项目按 2000 字符截断结果中文场景下实际 token 是 4000 多直接超长。正确做法是用 tokenizer 算或者至少按语言分别设置不同的字符阈值。第三个坑在 try 块末尾释放资源。一旦中间抛异常释放代码不会执行资源就泄漏了。这个坑最隐蔽因为正常流程下没问题只有异常时才暴露。但线上异常是常态所以必须放在 finally 里。4.3 一个实用的排查脚本我写了一个小脚本用来快速检查会话状态和上下文长度。每次遇到第二轮 500先跑这个脚本能快速定位到是哪一层的问题。import json import redis import tiktoken def diagnose(session_id, user_input, modelgpt-3.5-turbo): r redis.Redis(hostlocalhost, port6379, db0) key fsession:{session_id} raw r.get(key) if raw is None: print(第一层问题会话状态不存在) return history json.loads(raw.decode(utf-8)) print(f历史轮次数{len(history)}) encoding tiktoken.encoding_for_model(model) total 0 for turn in history: total len(encoding.encode(turn[user])) total len(encoding.encode(turn[assistant])) total len(encoding.encode(user_input)) print(f预估 token 数{total}) if total 3276: print(第二层问题上下文超出预算) else: print(第一、二层正常检查第三层资源释放)这个脚本不需要很复杂但能覆盖大部分排查场景。跑一次就能知道问题在哪一层比翻日志快得多。4.4 修复后的效果与经验总结三层都修完之后我们那个系统的第二轮 500 率从 15% 降到了 0.05% 以下。响应时间也从第二轮的 8 秒降到了 1.5 秒左右。连接池活跃数曲线变得很平稳不再出现只升不降的情况。我个人在实际操作中的体会是多轮对话的稳定性问题九成以上不是模型的问题而是工程链路的问题。模型只负责推理它不负责管理会话状态、不负责控制上下文长度、不负责释放连接。这些事都得工程代码来做。一旦工程代码有疏漏模型就会背锅。所以遇到第二轮 500先别急着换模型或者调参数。按“状态读取 → 上下文拼装 → 资源释放”的顺序查一遍大概率能找到根因。这三层里任何一层做扎实了都能挡住大部分问题三层都做扎实第二轮 500 基本就绝迹了。最后再分享一个小技巧在开发环境里加一个“模拟第二轮”的测试用例每次提交代码前跑一遍。这个用例不需要很复杂就是构造一个带历史记录的请求检查是否能正常返回。很多问题在开发阶段就能拦住不用等到线上才暴露。