上个月我负责的服务接入了大模型 API,上线前所有测试都过了,我甚至用脚本压了 50 个并发,本地表现一直很理想。结果真正放量之后,用户反馈接踵而至:回答到一半突然断了、页面转圈十几秒、多问几轮就开始报错。后台看监控,成功率 99% 以上,错误日志也干干净净,完全对不上号。
后来我才想明白,“AI API 调用成功”和“线上稳定”是两码事。前者只说明在某个时间点、某条网络路径、某个参数组合下,服务端返回了 200;后者要求整条链路上每一个环节都在各种并发和网络条件下稳定发挥。这篇文章就把我这次从“调通”走向“稳住”的完整过程写下来,涉及现象分类、根因定位、重试策略、流式连接细节,以及线上监控和降级方案。内容比较长,但每一条都是我实际踩过的坑,希望能帮你少走一些弯路。
1. 先分辨:你遇到的“不稳定”其实是四种不同的问题
很多人一看到“线上不稳定”就习惯性地怀疑模型服务商,然后一头扎进代码里调超时、加重试。但如果不先分清现象,很可能白忙一场。我自己的经验是,把问题分成四类,排查思路会清晰很多。
1.1 偶发超时:接口偶尔需要消耗 30 秒以上
表现形式是前端请求挂起,直到浏览器或网关报超时。这类问题要重点观察规律性:是集中在某个业务高峰时段,还是集中在某个地域的节点,或者只出现在特定长度和类型的请求上。如果规律不明显,很可能不是模型本身慢,而是网络链路、代理配置或容量规划出了问题。
1.2 流式输出中断:回答生成到一半就断掉
流式输出(SSE)是 AI 对话场景最常用的交互方式,但它比普通 JSON 请求脆弱得多。生成过程可能持续几十秒,反向代理默认的读超时只有 60 秒,中间任何一环掐断连接,用户看到的就是“答到一半没下文”。这种情况错误日志往往不记录,因为 HTTP 连接是被端侧断开,服务端并不一定产生了 5xx。
1.3 间歇性错误码:200、429、500 交替出现
如果日志里出现 429 限流、500 服务端错误、401 token 过期等状态码,并且是间歇性出现,就需要逐类分析。429 说明触发了配额限制;500 可能是服务商过载或模型推理服务异常;401 则可能是 token 过期但旧的缓存仍在使用。这三类问题的处理方式完全不同,混在一起做重试会非常危险。
1.4 请求成功但“内容质量”不稳定
还有一种更隐蔽的不稳定:接口始终返回 200,但生成结果时好时坏,或者到了某个时间点后回答质量明显下降。这种往往不是因为基础设施问题,而是上下文超长被截断、模型解码参数被重置、或者同一个 key 在不同会话之间共享了上下文。我们后面会细说。
为了方便定位,我习惯把这四种现象整理成一张表:
| 现象 | 典型根因方向 | 排查手段 | 常见误区 |
|---|---|---|---|
| 偶发超时 | 网络链路、代理缓冲、容量不足 | 抓取全链路耗时,观察分位点 | 盲目调大超时 |
| 流式断连 | 反向代理读超时、缓冲、心跳缺失 | 检查代理配置,抓包看字节流 | 只改前端重试 |
| 交替错误码 | 限流、token 过期、上游故障 | 按状态码分类统计 | 用统一点重试逻辑处理所有错误 |
| 内容不稳 | 上下文截断、参数漂移 | 记录请求体、token 使用量 | 误以为是模型效果问题 |
2. 一条完整链路拆解:从客户端到模型服务的五个检查点
确定现象类型后,下一步是把从用户浏览器到模型服务端的整条链路过一遍。很多时候问题根本不在模型 API,而在我们自己搭建的中间层。
2.1 本地能通,不代表线上网络路径能通
本地开发机通常有比较特殊的网络配置,而生产服务器是纯净的公网环境。我遇到过一次很典型的案例:本地请求第三方模型服务一切正常,线上却频繁超时。排查了半天,发现是生产服务器所在云厂商的出口 IP 被服务商风控系统标记,请求被静默丢弃。
如果你也遇到“本地好、线上差”的情况,先检查三件事:生产服务器的出口 IP 是否在服务商的允许名单里;DNS 解析是否正常;服务器到 API 网关的连通性是否稳定。可以在服务器上用 curl 反复请求同一个接口,观察响应时间和返回码分布,这是最快的手段。
2.2 反向代理的超时配置是第一个坑
很多团队会在应用前面挂一层 Nginx 或网关。Nginx 的proxy_read_timeout默认是 60 秒,对于普通 API 够用,但对于大模型生成接口来说远远不够。一个稍复杂的 prompt,模型推理时间很容易超过 30 秒,如果再加上排队时间,连接很可能在生成完成前就被代理断掉。
我建议按照实际业务场景配置:
proxy_connect_timeout 10s; proxy_send_timeout 300s; proxy_read_timeout 300s;同时设置proxy_buffering off,避免 Nginx 把 SSE 的流式内容暂时缓冲起来,导致前端拿不到增量数据。这个配置对流式接口尤其重要,后面讲 SSE 时会详细展开。
2.3 网关层的并发参数决定了一个“坏请求”能影响多少人
当多个请求同时打到模型 API 时,网关连接池的大小、worker 进程数、keepalive 参数都会成为瓶颈。默认情况下,每个 Nginx worker 能维护的连接数是有限制的,而模型 API 的响应慢,导致连接长时间被占用,新请求只能排队等待。
这不是让你盲目调大worker_connections,而是要根据服务商给的 RPM(每分钟请求数)和模型平均耗时来换算并发上限。我曾经踩过连接池耗尽导致整个服务雪崩的坑,后来统一了网关 keepalive 配置,问题才解决。
2.4 容器网络与 DNS 缓存会带来“幽灵”超时
如果你的服务跑在 Kubernetes 里,还会遇到一层容器网络的复杂性。CoreDNS 解析失败、节点之间网络策略限制、Service 端口映射配置错误,都可能表现为间歇性超时。最典型的例子是 DNS 超时:应用第一次调用 API 时解析域名,DNS 请求卡住,整个调用被阻塞;第二次调用时命中本地缓存,又恢复正常。
这类问题的排查逻辑是:先在 Pod 内部直接 curl API 域名,观察是否稳定;再检查应用的 DNS 缓存配置;最后看 CoreDNS 的监控指标。把链路一层层剥开,不要上来就怀疑模型服务。
2.5 SDK 和客户端库版本不一致也会导致行为差异
本地调试用的 Python requests 是最新版,生产环境却锁在某个旧版本;本地用 Node 18,线上是 Node 14;这些版本差异都可能带来超时行为、连接池管理、TLS 握手方式上的细微区别。
我现在的习惯是:把 AI API 调用的客户端版本也纳入依赖锁文件,并在测试环境完全复刻生产版本后再做上线前验证。不要总觉得“代码一样就没问题”,底层基础库的差异经常是线上不稳定的隐藏变量。
3. 第一个真正的元凶:token 上下文失控
把链路和代理层排查干净之后,我开始关注请求内容本身。结果发现真正的元凶之一,是 token 上下文管理失控。
3.1 一次间歇性 400 背后的规律
某个用户连续对话多轮后,接口开始随机返回 400,错误信息类似“model maximum context length is XXXXX tokens”。我最初以为这是个偶发 bug,后来发现规律很明显:对话轮数越多,越容易出现。也就是说,随着对话进行,请求体里的历史消息越来越长,最终超过了模型的上下文窗口上限。
这类错误不是每次请求都会触发,而是在用户某个特定轮次才突然爆发,所以日志里看起来是“间歇性”的。如果业务方不做任何处理,用户可以靠刷新页面暂时缓解,但下一次又要从头开始对话,体验非常糟糕。
3.2 为什么“调用成功”之后还会出现这种错误
很多接入方在原型阶段只做单轮问答,觉得模型返回正常就万事大吉。但真实用户会连续提问,对话历史会无限累积。如果不做裁剪,每个请求都会携带从第一轮开始到当前轮的全部消息。
这里有个被忽略的点:max_tokens只是限制本次生成的输出长度,并不能阻止你把超长请求体发给模型。模型的 context window 限制的是“输入 + 输出”的总 token 数,因此在组装请求体时,就必须预留足够的输出空间。
3.3 token 到底怎么数:别凭感觉估算
不同模型的 tokenizer 不一样,同一个中文字符可能对应 1 到 2 个 token。为了在生产环境精确控制请求体大小,我强烈建议使用官方 tokenizer 库或 API 返回的用量信息。
以 OpenAI 兼容接口为例,响应里通常会有usage.prompt_tokens和usage.completion_tokens。每次调用结束后,把这两个值记录下来,用来动态调整下一次请求的历史数量。
如果要离线估算,可以用这个简单逻辑:中文大约 1.5 字符对应 1 个 token,英文大约 4 个字符对应 1 个 token。但只用于估算,真正的预算管理还是要靠 tokenizer 计算。
3.4 滑动窗口和摘要压缩:两个最实用的裁剪方案
我的做法是把上下文管理抽象成一个独立的模块,核心策略是滑动窗口加摘要压缩。
滑动窗口的思路很简单:保留最近 N 轮对话,更早的历史直接丢弃。这样虽然可能丢失一部分早期信息,但在业务场景中,用户最关心的往往是最近几轮的内容。
如果业务不能接受完全丢弃,可以每隔几轮把之前的对话交给模型做一次摘要,并把摘要作为压缩后的历史消息参与后续请求。这一招特别适合客服、法律咨询这类需要长期记忆的场景。
代码层面大概是这样:
def build_messages(history, max_input_tokens=6000, reserve_output=1000): # 从后往前遍历历史,累积 token 数 messages = [] token_count = 0 for item in reversed(history): message = {"role": item["role"], "content": item["content"]} tokens = estimate_tokens(message) if token_count + tokens > max_input_tokens - reserve_output: break messages.append(message) token_count += tokens return list(reversed(messages))这段逻辑不复杂,但能把“按轮数粗剪”变成“按 token 精剪”,稳定性提升非常明显。
3.5 在业务层建立 token 预算
我还会在业务层给每个会话预设一个 token 预算,比如输入不超过 6000,输出不超过 1000。这样即使用户疯狂输入很长的内容,也不会把整个请求体撑爆。
除了输入侧控制,输出侧也要设置好max_tokens。有些模型支持动态调整最大输出长度,如果不显式设置,默认行为可能导致输出被截断,用户以为是服务不稳定,其实是 token 预算用完了。
4. 第二个真正的元凶:限流与重试风暴
另一个上线后才会逐渐暴露的问题,是限流和重试机制。这块处理不好,会直接把一次小故障放大成雪崩。
4.1 429 限流并不只有“每分钟请求数”一种
大多数模型服务商至少有三类限流维度:
- RPM:每分钟请求次数,适合短请求场景。
- TPM:每分钟 token 消耗量,适合长 prompt、长回答场景。
- 并发数:同时进行的 in-flight 请求数量。
只监控 RPM 很容易忽略 TPM 限流。比如你设置了每分钟 100 次请求,但每次请求都携带 5000 token 的上下文,很可能请求数没超,token 数先爆了。因此日志里要把请求成功、限流、token 用量全部记录下来,综合判断。
4.2 无脑重试让故障扩散
很多人的第一反应是“遇到 429 就重试”,这恰恰是最危险的做法。限流说明你已经超过了服务商的配额,继续重试只会让配额占得更满,其他正常业务也被拖下水。更极端的情况是,重试风暴打满服务商网关,导致你没有配额可用的时间里,所有请求都以 429 返回,形成恶性循环。
4.3 正确的重试策略:指数退避加抖动
重试只能用于暂时性错误,比如网络抖动、503、短暂过载。对于 429,如果在同一秒内重试,几乎必然失败。标准的做法是指数退避加随机抖动:
import random import time def call_with_retry(func, max_retries=4): for attempt in range(max_retries): try: return func() except RateLimitError: if attempt == max_retries - 1: raise delay = (2 ** attempt) + random.uniform(0, 1) time.sleep(delay)难点在于拿到 429 响应头里的Retry-After字段。服务商让你等多久就等多久,这个优先级高于任何退避算法。如果响应头里没有该字段,再用指数退避兜底。
4.4 客户端本地限流:避免把请求全堵在网络层
除了重试,还要在客户端实现本地限流。我常用信号量控制最大并发数,超过即排队或直接返回一个明确的错误码,而不是把所有请求都塞到模型 API 面前。
import threading semaphore = threading.Semaphore(5) def limited_call(func): with semaphore: return func()这能让系统在高峰期保持稳定,而不是被一小波突发流量打挂。
4.5 缓存和合并请求也是降峰手段
对于高频但内容相同的问题,缓存模型回答是性价比最高的方案。我遇到过一个客服场景,大量用户问“退款政策是什么”,模型每次都要跑一遍推理,费时又费钱。后来加了 Redis 缓存,命中率超过 80%,限流压力骤减。
如果请求参数相似但结果不允许缓存,还可以在业务层做请求合并:同一用户 5 秒内的相同请求只往上游发一次,其他请求等待结果返回后直接复用。
5. 流式输出(SSE)在线上特别容易踩的坑
对话类应用基本都会用 SSE 做流式输出,但这部分在本地调试时很难暴露问题,只有到了真实网络环境里才会花样百出。
5.1 数据到了代理层被缓冲,前端“卡死”
SSE 的目的是让用户尽快看到第一个 token。但反向代理默认开启缓冲,比如 Nginx 会把上游内容攒到一定大小再整体发给客户端,导致前端迟迟收不到数据,看起来就像卡住了。
解决方法是显式关闭代理缓冲,并增加响应头:
proxy_buffering off;在后端响应头里也需要标注:
X-Accel-Buffering: no Cache-Control: no-cache Content-Type: text/event-stream Connection: keep-alive这样 Nginx 和浏览器都不会对事件流做缓冲,用户才能第一时间看到内容在生成。
5.2 连接保持与空闲超时对抗
SSE 连接一旦建立,可能长时间维持,但并不是每时每刻都在传输数据。模型在推理时,可能是若干秒后才输出一个 token,此时连接处于空闲状态。如果代理层的proxy_read_timeout设置得太小,空闲时间一长就会断开连接,前端表现为“答到一半断了”。
我的做法是把proxy_read_timeout设置到 300 秒以上,并在应用层主动发送心跳注释行,比如每隔 15 秒发送一个: keep-alive。注释行对 SSE 协议来说是合法的,能有效防止中间网络设备因为空闲而掐断连接。
5.3 前端也需要心跳和自动重连机制
即使服务端做了心跳,前端的处理也不能马虎。我见过不少项目用原生 EventSource,服务端一断连接就直接白屏,没有任何提示。
更稳妥的做法是用 fetch 自己读取流,并在连接异常时按策略重连:
async function connectSSE(url, onMessage) { while (true) { try { const response = await fetch(url, { headers: { Accept: 'text/event-stream' } }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 解析 SSE 事件并调用 onMessage } } catch (e) { // 指数退避重连 await new Promise(resolve => setTimeout(resolve, 1000)); } } }这里要注意,不要用EventSource直接通信,因为很多服务商要求通过自定义 Header 携带 token,而EventSource不支持自定义 Header。fetch 流式读取是目前最通用的方案。
5.4 服务端要避免一次性把整段文本塞进事件
有些开发者图省事,让模型完整生成完后一次性通过 SSE 发出去。这虽然也是合法 SSE,但完全失去了流式输出的意义,用户体验和普通 POST 请求没有区别,还白白占用长连接。
正确的做法是模型每产出一小段 token,就立即包装成data:格式发送到客户端。这样首字节时间可以从好几秒降到几百毫秒,用户感知到“响应很快”。
6. 稳定性不是靠运气:监控、预警与降级
解决完上面几个根因后,线上已经稳定很多。但我特别想强调,稳定不是靠一次排查就一劳永逸的,必须有监控和预案兜底。
6.1 必须埋点的四个指标
我建议最少埋四个指标:
| 指标 | 采集方式 | 建议告警阈值 |
|---|---|---|
| 请求成功率 | 统计非 2xx 与总请求数之比 | 低于 99% 预警 |
| P50 / P95 / P99 延迟 | 记录每个请求的总耗时,计算分位点 | P95 超过 10 秒预警 |
| token 用量 | 从响应 usage 字段获取 | TPM 达到配额 80% 预警 |
| 限流次数 | 单独统计 429 状态码 | 连续 5 次预警 |
尤其是延迟,不要只看平均值。平均值非常容易被少数慢请求拉高,但也会隐藏大量快速请求。P99 能反映最差的一批用户体验,这才是线上稳定性最重要的参考。
6.2 用 trace_id 打通全链路日志
当某个请求慢到不可接受时,如果没有链路追踪,你很难判断时间花在了哪一段。我的方案是在入口处生成一个trace_id,然后在网关、应用层、模型 API 调用层、日志系统里都打印这个字段。
这样定位问题时,只需要查一个trace_id,就能看到:请求在队列里等了多久、反向代理转发花了多久、模型 API 返回用了多久、业务处理消耗了多久。没有 trace_id 的日志,在分布式环境里基本等于没有意义。
6.3 降级方案不能等到故障发生才设计
模型 API 是第三方服务,谁也无法保证永远稳定。我强烈建议提前设计好降级策略,至少有这三级:
- 第一级:简单问题返回预置答案,比如高频业务问答可以走缓存或本地规则。
- 第二级:切换备用模型服务商,虽然效果不一定完全一致,但至少保证业务可用。
- 第三级:关闭流式输出,退化为完整返回摘要,或者提示用户稍后再试。
降级策略的关键是自动触发,而不是人工介入。监控系统检测到连续多次 5xx 或超时,就应该自动切换流量到备用通道。
6.4 容量规划公式:你需要的并发没你想的那么多
计算并发需求的公式很简单:
最大并发 = 每分钟允许的请求数 × 平均请求耗时(秒) / 60假设服务商给你 60 RPM,平均每个请求耗时 20 秒,那么理论上只需要 20 并发就能打满配额。但如果有 100 个用户同时点击,你需要在客户端做排队,而不是全部一股脑发出去。
部署模型 API 网关时,我会预估峰值流量并留出 1.5 倍余量,同时在网关层配置超时熔断,避免一个慢接口拖垮整个服务。
7. 生产环境里的经验复盘:三个我真实踩过的坑
到这里,技术层面的排查思路已经讲完。最后分享三个我在生产环境里遇到的具体故障,每一个都花了不少时间才定位到根因。
7.1 故障一:连接池配置太大反而惹祸
某个服务用 Java 的 RestTemplate 调用模型 API,平时一切正常。某天大促流量上来后,突然出现大量连接超时,进程 CPU 也没高,日志里全是“Connection pool timeout”。排查后发现是连接池最大连接数设得很高,但模型 API 的平均响应时间很长,导致连接全部被占用,新任务拿不到连接只能干等。
解决方法是把连接池大小与模型 API 的并发限制对齐,同时给连接获取操作设置明确的等待超时,避免线程无限阻塞。这个问题以前在普通 API 上不明显,因为普通 API 响应快、连接周转快;但大模型 API 动辄十几秒,连接池设计思路完全不同。
7.2 故障二:只监控平均值,P99 恶化到 40 秒才发现
有段时间用户频繁反馈“特别慢”,但后台监控显示平均延迟只有 3 秒。我一直不理解问题出在哪,直到把延迟按分位点拆开,才发现 P99 已经接近 40 秒,P50 只有 1.5 秒。也就是说,大部分请求很快,但每 100 个用户里就有 1 个体验极差,这种用户虽然不多,但在社交平台上吐槽的往往就是他们。
从那以后,我把所有外部调用的监控全部改成“平均值 + 分位点”双看,尤其关注 P99。对 AI API 这种强依赖第三方服务的场景,只看平均延迟完全是在自欺欺人。
7.3 故障三:连接复用后出现大量 TIME_WAIT
某个服务开启 keepalive 后,网关机器的连接数反而暴涨。用ss -s一看,大量连接处于 TIME_WAIT 状态。原因是我们虽然复用了连接,但模型 API 每次返回后主动关闭连接,且我们这边没有正确复用,导致每次请求都新建一条 TCP 连接,最后积压了大量 TIME_WAIT socket。
解决方式是调整客户端连接池的 keepalive 时间,同时确认服务器端也启用了 HTTP keepalive,保证一条连接能够服务多个请求。这个问题在长耗时接口上尤其明显,因为连接占用时间长,复用率低,TIME_WAIT 的数量会呈指数级增长。
如果你也正准备上线 AI 相关功能,我的建议是先别急着迭代产品功能,花一天时间把上面这些链路检查一遍。我在这次排查里最大的收获不是修好了某一个 bug,而是建立了一套“稳定性基线”:成功率、P95、限流次数、token 用量全部量化,任何指标异常都能第一时间定位到具体环节。这套方法放到任何一家模型服务商身上都通用,毕竟外部 API 永远会有波动,真正决定线上体验好坏的,是你自己在周边系统里打下了多深的基础。