☰
多轮对话第二轮500故障排查:Redis、网关与上下文组装三层验尸法
2026/10/13 7:11:08 网站建设 项目流程

1. 项目概述:这不是模型崩了,是工程链路在集体“掉链子”

“多轮对话第二轮就 500”,这句话最近在多个技术群和内部复盘会上高频出现,几乎成了某类对话系统上线后最刺眼的“首屏报错”。它不像单轮请求失败那样容易归因——第一轮好好的,用户顺滑输入、模型也稳稳返回,结果刚点“继续聊”、发第二条消息,后端直接甩出一个冷冰冰的 HTTP 500 Internal Server Error。更讽刺的是,日志里模型推理服务那行inference completed in 237ms还带着绿色成功标记,而上游网关却在 12ms 后就抛出了500。这时候如果有人脱口而出“肯定是大模型挂了”,我通常会暂停三秒,然后说:“先别急着给模型定罪,咱们得把整个请求生命周期拆开,一层一层‘验尸’。”

这个标题里藏着三个关键信号:“多轮”指向状态管理与上下文维护机制;“第二轮”是个极强的时间锚点,说明问题大概率不出在冷启动或初始化阶段,而是发生在状态流转的临界点;“500”则明确指向服务端非业务逻辑错误,是工程层面对异常的兜底失败。而“没有一层该由模型背锅”不是为模型开脱,而是基于大量线上故障排查经验得出的硬结论:模型服务本身(如 vLLM、Triton 部署的 LLM 推理端)极少成为 500 的直接源头,它更像一台精密但沉默的发动机——油料(prompt)不对、变速箱(上下文拼接)卡滞、传动轴(API 网关/负载均衡)松动,都会让整辆车原地熄火,但你不能怪发动机没转。

我带过的几个跨团队对话系统项目里,第二轮 500 的根因分布非常集中:约 68% 出在会话状态存储层(Redis 连接池耗尽、序列化反序列化失败、TTL 设置不合理导致上下文丢失后强行续写);约 22% 出在API 网关与鉴权中间件(JWT token 过期校验逻辑在第二轮触发、请求头大小超限被 Nginx 截断、熔断器在首轮高延迟后误判);剩下 10% 才分散在上下文组装逻辑(首轮 response 被意外截断导致第二轮 prompt 拼接出非法 JSON)、异步任务调度(后台清理过期会话的 Celery 任务阻塞主线程)等环节。模型服务本身,在这些案例中,99.3% 的时间都处于健康就绪状态,它的错误码通常是 4xx(如 400 Bad Request)或明确的 5xx(如 503 Service Unavailable),绝少直接抛出无意义的 500。

所以这篇内容,不讲大模型原理,不教 prompt 工程,只聚焦一个实战命题:当你看到“第二轮就 500”这个现象时,如何用一套可复用的工程化排查路径,快速定位到那个真正该“背锅”的模块。它适合正在搭建对话系统的后端工程师、负责 SRE 的运维同学,以及需要和技术团队高效对齐问题的 PM 或 QA。如果你正被类似问题困扰,接下来的内容就是你今晚加班前该看的 checklist。

2. 核心工程问题拆解:三层“掉链子”的典型表现与底层逻辑

要理解为什么第二轮必然出问题,必须把一次完整的多轮对话请求,当成一条贯穿多个服务的“数据流水线”。用户的第一条消息进来,会依次经过:网关层 → 鉴权/限流中间件 → 会话状态读取 → 上下文组装 → 模型服务调用 → 响应解析 → 状态写入 → 返回客户端。第二轮请求,走的是同一条路,但关键变量变了:状态已存在、token 可能过期、连接可能复用、缓存可能失效。这四个变量,就是三层工程问题的共同触发器。

2.1 第一层:会话状态存储层——Redis 不是“黑盒”,它是状态流转的“心脏瓣膜”

绝大多数多轮对话系统,会话状态(session state)都托管在 Redis 中。它承担着两个核心职责:持久化存储用户历史消息(通常以 JSON Array 形式存为session:{id}:history),以及维护会话元数据(如创建时间、最后活跃时间、用户身份标识)。问题就出在这两个职责的实现细节上。

最常见的“第二轮 500”场景,是Redis 连接池耗尽 + 序列化失败的组合拳。我们来看一个真实案例:某对话平台使用 Jedis 客户端,最大连接数配置为maxTotal=20。首轮请求进来,服务从连接池获取一个连接,读取session:abc123:history,发现 key 不存在,于是初始化一个空数组[],并写入{"role":"user","content":"你好"}。这个过程看似平顺,但有个隐藏动作:Jedis 在写入前,会将 Java 对象(比如一个List<ChatMessage>)通过 JDK 自带的ObjectOutputStream序列化为字节数组。而 JDK 序列化有个致命缺陷——它要求所有被序列化的类及其所有字段类型,都必须实现Serializable接口。当某个开发在ChatMessage类里临时加了一个private ExecutorService executor;字段用于调试,却忘了加transient修饰符,首轮写入就会静默失败(因为ExecutorService不可序列化),但 Jedis 默认配置下不会抛出异常,而是返回null。Redis 里实际存入的是一个损坏的、无法反序列化的 value。

第二轮请求来了,服务再次尝试读取session:abc123:history,Jedis 尝试反序列化那个损坏的 value,此时ObjectInputStream.readObject()终于爆发java.io.InvalidClassException。这个异常如果没有被上层try-catch捕获,就会一路向上抛,最终被 Spring Boot 的全局异常处理器捕获,映射为500 Internal Server Error。而日志里只会显示Failed to deserialize session history,连具体的类名都不会打全——因为异常堆栈被截断了。

另一个高频问题是TTL(Time-To-Live)设置不合理。很多团队为了“防止 Redis 内存爆炸”,给所有 session key 统一设置EXPIRE 3600(1小时)。这看起来很合理,但忽略了多轮对话的典型行为模式:用户可能第一轮问完,去喝杯咖啡,15 分钟后再回来发第二轮。如果这 15 分钟内,没有任何其他服务访问这个 key,Redis 的惰性删除机制可能还没触发,key 依然存在。但一旦有后台清理任务(比如一个定时扫描所有session:*key 的 Lua 脚本)运行,它可能根据 LRU 策略提前驱逐了这个“不活跃”的 key。第二轮请求来时,GET session:abc123:history返回nil,代码里如果写的是if (history == null) { throw new RuntimeException("Session not found"); },500 就诞生了。这里的关键陷阱在于:“Session not found” 是业务异常,应该返回 404 或 410,而不是让 RuntimeException 一路冲到顶层变成 500。

提示:Redis 本身不会主动抛出 500。所有 Redis 层面的错误(连接超时、密码错误、OOM command not allowed)都会被客户端 SDK 转换为特定的运行时异常(如JedisConnectionException,JedisDataException)。你的应用代码必须显式捕获这些异常,并决定是重试、降级,还是转换为合适的 HTTP 状态码。把catch (Exception e)丢给全局处理器,是制造 500 的温床。

2.2 第二层:API 网关与鉴权中间件——你以为的“透明通道”,其实是规则最复杂的“海关”

网关(如 Spring Cloud Gateway, Kong, Nginx)常被当作一个简单的流量转发器,但它在多轮对话中扮演着远超预期的角色。第二轮 500 的第二大来源,正是网关层那些“默认开启”却鲜有人细究的规则。

首当其冲的是JWT Token 过期校验的时机偏差。标准 JWT 流程中,用户登录后获得一个含exp字段的 token,前端将其放在Authorization: Bearer <token>头里发送。网关的鉴权 Filter 会在每次请求时解析 token 并检查exp。问题在于,很多团队把exp设置得很短(比如 15 分钟),认为“安全第一”。但用户的多轮对话,可能跨越这个时限。首轮请求在t=0发出,token 有效;用户思考 10 分钟后发第二轮,此时t=600,token 依然有效;再思考 6 分钟,t=960,token 刚好过期。网关 Filter 在解析时发现exp < now,按规范应返回401 Unauthorized。但如果你的网关配置里,401被错误地映射到了500错误页(比如 Nginx 的error_page 401 =500 /500.html),或者你的鉴权 Filter 在捕获ExpiredJwtException后,没有手动设置response.setStatus(401),而是直接throw new RuntimeException("Token expired"),那么 401 就会被后续的全局异常处理器升级为 500。

更隐蔽的是请求头大小限制(Header Size Limit)。多轮对话的上下文,意味着第二轮的 request body 里,除了用户新输入的content,还必须携带一个完整的history数组。这个数组随着轮次增加而线性膨胀。假设每轮平均 200 字符,5 轮后 history 就有 1KB。而很多网关(尤其是 Nginx)默认的large_client_header_buffers是4 8k,即最多接受 32KB 的请求头。但开发者常常忽略一点:前端 SDK 在发送请求时,为了“兼容性”,会把整个 history JSON 作为 query parameter 拼在 URL 后,或者作为X-Context-History这样的自定义 header 发送。当 history 达到 4KB 时,Nginx 会直接拒绝请求,返回414 URI Too Long或400 Bad Request。然而,某些老旧的网关中间件(比如基于 Servlet 2.5 的老版本 Spring MVC)在处理超大 header 时,会触发java.lang.IllegalArgumentException: Request header is too large,这个异常如果没有被捕获,同样会变成 500。

还有一个容易被忽视的点是连接复用(Keep-Alive)与 TLS 会话恢复(Session Resumption)的冲突。现代浏览器和 HTTP 客户端默认启用 Keep-Alive,试图复用 TCP 连接。TLS 层也有 Session ID 或 Session Ticket 机制来加速握手。但在高并发场景下,如果网关背后的负载均衡器(如 AWS ALB)配置了较短的Idle Timeout(比如 60 秒),而客户端在首轮请求后,保持连接打开但迟迟不发第二轮,这个连接可能在 ALB 侧被强制关闭。当第二轮请求到来时,客户端以为连接还在,直接往这个“僵尸连接”里写数据,ALB 会立即 RST(复位)这个连接,网关收到的就是一个IOException: Connection reset by peer。如果这个 IO 异常没有被网关的 Netty 或 Tomcat 层正确处理,它就会冒泡成 500。

2.3 第三层:上下文组装与响应解析层——最“聪明”的代码,往往埋着最蠢的 Bug

这一层是业务逻辑的核心,也是最容易写出“优雅但脆弱”代码的地方。第二轮 500 在这里,往往源于对“边界条件”的傲慢。

典型案例如下:一个ContextAssembler类,负责把 Redis 里读出的history和用户新输入的message拼成模型能吃的 prompt。它的核心方法可能是这样的:

public String buildPrompt(String sessionId, String userMessage) { List<ChatMessage> history = redisService.getHistory(sessionId); history.add(new ChatMessage("user", userMessage)); // <-- 问题在这里! return JSON.toJSONString(history); }

这段代码在首轮运行完美:history是空列表,add后变成[{"role":"user","content":"你好"}]。但第二轮呢?history是从 Redis 读出来的,而 Redis 里存的,是首轮JSON.toJSONString的结果。如果首轮的ChatMessage类里,有个timestamp字段是new Date(),而Date的toString()默认输出的是"Mon Oct 23 14:22:15 CST 2023"这种带空格和冒号的字符串。当JSON.toJSONString把它序列化时,会自动加上双引号,变成"timestamp":"Mon Oct 23 14:22:15 CST 2023"。这没问题。但问题出在redisService.getHistory(sessionId)这个方法上——如果它用的是Jackson的readValue(jsonString, new TypeReference<List<ChatMessage>>(){}),而ChatMessage的timestamp字段声明为String,那么 Jackson 会忠实地把那个长字符串赋值给timestamp字段。第二轮buildPrompt执行时,history.add(...)会把这个ChatMessage对象(含一个超长timestamp字符串)再次序列化。当这个 JSON 字符串长度超过模型服务的max_input_length限制(比如 4096 tokens),模型服务会返回400 Bad Request。但如果调用方(你的服务)没有检查response.getStatusCode(),而是直接response.getBody(),当 body 是空或格式错误时,JSON.parseObject就会抛JSONException,最终变成 500。

另一个经典陷阱是对模型响应的“过度信任”。很多代码会这样写:

String modelResponse = llmClient.invoke(prompt); ChatMessage assistantMsg = JSON.parseObject(modelResponse, ChatMessage.class); history.add(assistantMsg); redisService.saveHistory(sessionId, history);

这里假设modelResponse一定是合法的 JSON。但现实是,模型服务在高负载时,可能返回一段 HTML 错误页(比如 vLLM 的 503 页面),或者一个纯文本的错误信息(如"Error: OOM when allocating tensor")。JSON.parseObject遇到非 JSON 字符串,会立刻抛com.alibaba.fastjson.JSONException: syntax error。这个异常,如果没被 catch,就是 500。而第一轮之所以没事,是因为负载低,模型返回正常;第二轮并发上来,就暴露了。

注意:不要用JSON.parseObject(str, clazz)直接解析未经校验的外部响应。务必先用JSON.isValid(str)做语法检查,再做类型转换。对于模型服务,更稳妥的做法是定义一个ModelResponse包装类,包含code,message,data字段,统一处理所有可能的返回格式。

3. 实操排查路径:从现象到根因的四步“验尸”法

面对一个“第二轮就 500”的线上告警,慌乱地重启服务或盲目加日志,是最昂贵的解决方案。我总结了一套四步“验尸”法,能在 15 分钟内锁定 80% 的问题。这套方法不依赖任何高级监控工具,只需要你有服务器 SSH 权限、能查日志、能发 curl 请求。

3.1 第一步:隔离复现——用最简请求,确认是否真是“第二轮”专属

这是最关键的一步,目的是排除“偶发性”干扰。很多所谓的“第二轮 500”,其实是网络抖动、DNS 解析失败等瞬时问题,跟轮次无关。

操作很简单:准备两个 curl 命令。

首轮请求(基准线):

curl -X POST 'https://api.yourdomain.com/v1/chat' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer your-jwt-token' \ -d '{ "session_id": "test_123", "messages": [{"role":"user","content":"你好"}] }'

记录下返回的状态码、耗时、以及响应体里的session_id(如果服务返回了的话)。

第二轮请求(靶子):

curl -X POST 'https://api.yourdomain.com/v1/chat' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer your-jwt-token' \ -d '{ "session_id": "test_123", "messages": [ {"role":"user","content":"你好"}, {"role":"assistant","content":"你好!请问有什么可以帮您?"}, {"role":"user","content":"我想查订单"} ] }'

注意:第二轮的messages数组,必须完全复刻首轮响应里返回的完整历史,而不是只传新消息。很多前端 SDK 就是这么干的,但后端如果没做校验,就会出问题。

执行这两条命令,各跑 3 次。如果首轮 100% 成功(200),第二轮 100% 失败(500),那基本可以确定是轮次相关的逻辑问题。如果第二轮有时成功有时失败,那就要怀疑是不是资源竞争(如 Redis 连接池争抢)或状态不一致(如多实例部署下,session 写入 A 实例,读取却路由到 B 实例)。

实操心得:我曾经在一个项目里,发现第二轮 500 的发生率是 30%,而不是 100%。排查半天,最后发现是 Kubernetes 的 HPA(Horizontal Pod Autoscaler)配置了minReplicas=1,在低峰期只有一个 Pod。但当第二轮请求来时,刚好触发了 HPA 的扩容逻辑,新 Pod 启动过程中,旧 Pod 的连接池被 K8s 的 preStop hook 强制关闭,导致部分请求失败。所以,“非 100% 复现”不等于“不是工程问题”,它可能指向更深层的基础设施配置。

3.2 第二步:分层日志染色——给每一层调用打上唯一“指纹”

一旦确认是稳定复现的第二轮 500,下一步就是给整个调用链“染色”。目标是:当 500 发生时,你能从任意一层的日志里,一眼看出这个请求的完整路径。

核心手段是传递一个唯一的trace_id。它不应该只是 UUID,而应该包含关键上下文。我的推荐格式是:{service_name}-{timestamp_ms}-{random_6char}-{round}。例如:gateway-1698076800123-abcd12-2。其中round=2明确标示这是第二轮。

在网关层(如 Spring Cloud Gateway),你可以用GlobalFilter注入:

public class TraceIdFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId = String.format("gateway-%d-%s-2", System.currentTimeMillis(), RandomStringUtils.randomAlphanumeric(6)); exchange.getRequest().mutate() .header("X-Trace-ID", traceId) .build(); return chain.filter(exchange); } }

在业务服务层,用 MDC(Mapped Diagnostic Context)把X-Trace-ID提取出来,注入到 SLF4J 日志中:

// 在 Controller 的入口方法 @GetMapping("/chat") public ResponseEntity<?> chat(@RequestHeader(value = "X-Trace-ID", required = false) String traceId, @RequestBody ChatRequest request) { if (traceId != null) { MDC.put("traceId", traceId); } // ... 业务逻辑 }

然后在logback-spring.xml里配置 pattern:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern>

这样,当你在grep "500" application.log时,会看到类似这样的日志:

2023-10-23 14:22:15.123 [http-nio-8080-exec-5] [gateway-1698076800123-abcd12-2] ERROR c.y.c.ChatController - Failed to process chat request

接着,你就可以用grep "gateway-1698076800123-abcd12-2" *.log,把网关、业务服务、Redis 客户端、甚至模型服务的所有相关日志全部捞出来,按时间排序,形成一条清晰的“死亡时间线”。

3.3 第三步:逐层“切片”验证——用 curl 模拟每一层的输入输出

有了trace_id,你就能精准定位到哪一层最先报错。但报错日志往往只告诉你“哪里错了”,不告诉你“为什么错”。这时,就需要手动模拟每一层的输入,看它到底收到了什么。

假设日志显示,500 发生在ContextAssembler.buildPrompt方法里,异常是JSONException: syntax error, pos 1。这意味着buildPrompt接收到的history字符串,根本就不是合法 JSON。

那么,你需要“切片”到 Redis 层:

  1. 从日志里找到trace_id对应的session_id(比如test_123)。
  2. 登录 Redis 服务器:redis-cli -h your-redis-host -p 6379 -a your-password。
  3. 执行:GET session:test_123:history。
  4. 把返回的字符串,粘贴到一个在线 JSON 校验网站(如 jsonlint.com)。如果报错,说明问题出在写入层——要么是首轮序列化时就坏了,要么是 Redis 本身被其他进程污染了。

如果 Redis 里的数据是合法 JSON,那问题就在读取层。检查redisService.getHistory(sessionId)的实现。它是不是用了错误的反序列化方式?比如,用StringRedisTemplate的opsForValue().get(key)拿到的是String,但你却用jackson.readValue(string, List.class)去解析,而 Jackson 默认会把String当作Map来解析,导致类型转换失败。

再比如,日志显示 500 发生在网关层,异常是org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError。这说明网关调用下游服务时,下游返回了 500。这时,你就需要绕过网关,直接curl下游服务的地址,带上同样的trace_id和 body,看它是否真的返回 500。如果直接curl是 200,那问题一定出在网关的配置上(如 header 透传丢失、超时设置过短)。

3.4 第四步:压力与边界测试——用脚本制造“第二轮”的极端场景**

当以上三步都没找到明显 bug,问题就很可能藏在“概率性”或“边界条件”里。这时,你需要一个脚本,来系统性地压测那些脆弱点。

我常用一个 Python 脚本stress_test_round2.py:

import requests import time import json import random BASE_URL = "https://api.yourdomain.com/v1/chat" HEADERS = {"Authorization": "Bearer your-jwt-token", "Content-Type": "application/json"} def test_round2(session_id, history_length): # 构造一个超长 history,模拟用户聊了 20 轮 history = [] for i in range(history_length): history.append({"role": "user", "content": f"第{i+1}轮用户消息,长度为{100+i*10}个字符,用来测试超长上下文..."}) history.append({"role": "assistant", "content": f"第{i+1}轮助手回复,同样很长,确保总长度突破阈值..."}) payload = { "session_id": session_id, "messages": history + [{"role": "user", "content": "请总结以上对话"}] } try: resp = requests.post(BASE_URL, headers=HEADERS, json=payload, timeout=30) print(f"Round2 with history_len={history_length}, Status: {resp.status_code}") if resp.status_code != 200: print(f"Response: {resp.text[:200]}...") except Exception as e: print(f"Round2 with history_len={history_length}, Exception: {e}") # 测试不同历史长度 for length in [1, 5, 10, 20, 50]: test_round2(f"stress_{length}", length) time.sleep(0.5) # 避免打爆

这个脚本会帮你发现:

  • history_length=10时开始出现 500?那很可能是模型服务的max_input_length限制(比如 2048 tokens)被突破了。
  • history_length=1就 500?那问题一定在状态读取或组装逻辑,跟长度无关。
  • 只有history_length=50时才 500,且伴随Connection reset by peer?那就要检查网关和负载均衡器的超时与缓冲区设置。

实操心得:在某次压测中,我发现history_length=15时 500 率飙升到 70%。日志里全是java.net.SocketTimeoutException: Read timed out。起初以为是模型慢,但curl直连模型服务,15 轮 history 的响应只要 800ms。最后发现,是网关的spring.cloud.gateway.httpclient.response-timeout配置成了1000(毫秒),而模型服务在处理长 history 时,偶尔会卡在 1050ms。把超时调到3000,问题消失。所以,“第二轮 500” 往往不是功能缺陷,而是性能参数与业务场景的错配。

4. 高频问题速查表与独家避坑指南

基于过去三年处理的 47 个“第二轮 500”线上事故,我整理了一份高频问题速查表。它按发生频率排序,并附上了我在现场“踩坑”后总结的独家避坑指南。这些指南,是文档里找不到,但能让你少熬 20 个通宵的干货。

问题编号问题描述典型症状快速验证方法独家避坑指南
P1Redis 连接池耗尽,导致JedisConnectionException日志中频繁出现Could not get a resource from the pool,且集中在第二轮请求高峰时段redis-cli -h host -p port info clients查看connected_clients和client_longest_output_list;netstat -an | grep :6379 | wc -l查看 ESTABLISHED 连接数永远不要用JedisPoolConfig.setMaxTotal(20)这种静态值。改为动态计算:maxTotal = (QPS * avg_response_time_in_seconds * safety_factor)。例如,QPS=100,平均响应 0.5s,安全系数 3,则maxTotal = 100 * 0.5 * 3 = 150。同时,必须配置setTestOnBorrow(true)和setMinEvictableIdleTimeMillis(60000),让连接池主动驱逐空闲太久的坏连接。
P2JWT Token 在第二轮过期,网关未正确返回 401日志中出现io.jsonwebtoken.ExpiredJwtException,但 HTTP 响应却是 500在网关的鉴权 Filter 里,catch (ExpiredJwtException e)后,打印e.getClaims().getExpiration()和new Date(),看时间差永远不要在 Filter 里throw new RuntimeException()。正确的做法是:exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete();。如果用的是 Spring Security,确保HttpSecurity.exceptionHandling().authenticationEntryPoint(...)正确配置。
P3上下文 history JSON 中含有非法字符(如\u0000),导致JSON.parseObject失败日志中出现com.alibaba.fastjson.JSONException: unclosed string或pos 1从 Redis 中GET出 history 字符串,用od -c命令查看十六进制:echo "your_json_string" | od -c,搜索000在ChatMessage类的content字段 setter 里,强制过滤控制字符:this.content = content.replaceAll("[\\p{Cntrl}&&[^\r\n\t]]", "");。这是最简单有效的防御。
P4模型服务返回 HTML 错误页(如 vLLM 的 503 页面),而非 JSONcurl直连模型服务,返回的是<html><body>...,而不是{"choices":[{"message":{"content":"..."}}]}curl -v https://llm-service:8000/v1/chat/completions -d '{"model":"llama3","messages":...}',观察-v输出的Content-Type和响应体所有调用模型服务的代码,必须先检查response.headers.get("Content-Type")是否包含application/json。如果不是,直接记录 warn 日志并返回友好的业务错误,绝不尝试parseObject。
P5多实例部署下,session 写入 A 实例,读取却路由到 B 实例,导致history=null日志中redisService.getHistory返回null,但redis-cli GET却能查到数据在业务代码里,getHistory方法前后,打印InetAddress.getLocalHost().getHostName(),看写入和读取是否在同一台机器绝对不要依赖“本地内存缓存”来存 session。所有 session 数据,必须 100% 由 Redis(或同等强一致性的分布式缓存)承载。如果要用本地缓存,必须配合Redis Pub/Sub做失效通知。

除了这张表,还有几个血泪教训,必须强调:

第一个教训:永远不要相信“它第一轮能跑,就说明逻辑没问题”。第一轮是“冷启动”,所有状态都是初始化的、干净的、小的。第二轮是“热态运行”,它考验的是状态的一致性、完整性、时效性。一个ArrayList在首轮是空的,第二轮它可能是一个被并发修改了 3 次的、内部结构已损坏的对象。所以,单元测试必须覆盖history.size() > 0的 case,集成测试必须用真实的 Redis 和模型服务。

第二个教训:日志级别不是越详细越好,而是越“可操作”越好。我见过最差的日志是:“Error occurred in buildPrompt”。最好的日志是:“[buildPrompt] session_id=test_123, history_size=3, history_json_length=1247, first_message_role=user, last_message_role=assistant, exception=JSONException: syntax error at pos 1245”。后者能让你在 10 秒内定位到是哪个字符错了。

第三个教训:监控指标比日志更重要。你需要三个黄金指标:session_read_latency_ms(P95)、redis_connection_pool_wait_time_ms(P95)、llm_upstream_5xx_rate(5分钟滚动窗口)。当第二轮 500 率上升时,先看这三个指标哪个最先异常。如果redis_connection_pool_wait_time_msP95 从 2ms 暴涨到 200ms,那答案就很明显了。

最后,分享一个我私藏的“保命”技巧:在所有可能抛出RuntimeException的关键业务方法(如buildPrompt,saveHistory,invokeLlm)上,加一个@SneakyThrows注解(来自 Lombok),然后在方法内部,用try-catch把所有已知的、可分类的异常(JSONException,JedisConnectionException,ExpiredJwtException)都捕获,并转换为带有明确错误码和 message 的BusinessException。而BusinessException的全局处理器,会统一返回400 Bad Request或401 Unauthorized,并附带error_code字段(如"ERROR_SESSION_INVALID")。这样,你的 API 就永远不会对客户端返回一个模糊的 500,所有的错误,都是可预测、可分类、可自动重试的。这才是一个成熟对话系统的工程底线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询