日期:2026-08-09
这个问题是被用户投诉逼出来的。客户运营一个面向企业客户的 AI 服务平台,调用走自家的 AI 网关,按 token 计费。有客户反馈同一个问题收到了两条几乎一样的回答,还有客户对着账单说自己明明只调了一次,账单上是两条记录。
平台团队最初以为是客户端重复提交,让客户加了防抖,问题没解决。后来查网关日志才定位到根因:网关对后端模型服务的调用设了超时和重试,某些情况下模型服务其实已经开始处理甚至处理完了,只是响应因为网络抖动或者服务端 GC 停顿没能及时回来,网关判超时后发起重试,后端就把同一个请求算了两遍。
翻了三个月的日志,这类情况的发生率是万分之七点四。听起来不高,但平台日调用量在两百万上下,算下来每天有一百五十次左右重复执行。算力浪费是一方面,更麻烦的是计费。财务每月要处理二三十起计费争议,每次都要拉日志人工核对,一起争议平均花掉四十分钟。
还有个更隐蔽的问题。有些调用是带副作用的,模型输出会触发下游的业务动作,比如自动创建工单、发送通知。重复执行意味着工单建了两个、通知发了两遍。这块出过一次事故,某客户的自动化流程因为网关重试,给同一个终端用户连发了三条短信。
我们在网关层做了一整套幂等保障,业务方不需要改代码,但可以通过请求头传入自定义幂等键来获得更精确的控制。整体四块。
请求幂等键设计是基础。每个进入网关的请求会得到一个幂等键,优先使用客户端传入的,客户端没传的话由网关按请求内容生成。这个键是后续所有去重逻辑的锚点。
结果缓存与去重负责拦截重复执行。幂等键第一次出现时,网关记录一个"处理中"标记;如果在结果产出前又来了相同键的请求,后来的请求不会触发新的模型调用,而是挂起等待第一个请求的结果。已完成的请求,其结果在缓存里保留一段时间,期间相同键的请求直接返回缓存结果。
重试边界判定解决的是"什么情况下才该重试"。原来的逻辑是超时就重试,太粗暴。新逻辑会区分请求是否可能已经被后端接收和处理。
计费对账修正是最后一道。即使前面几层都拦住了,仍可能有极少数重复计费漏网,需要有机制在对账阶段识别并冲正。
幂等键的生成规则纠结了很久。客户端传入是最理想的,但现实是大部分客户端不传,尤其是那些用标准 SDK 直接对接的。网关自己生成的话,用什么做输入是个问题。
第一版我们用"请求体全文的哈希加上租户 ID",跑了一周发现误判率高得离谱。原因是有很多合理的重复请求:用户对着同一个问题连点了两次生成按钮,希望得到两个不同的答案;批量任务里本来就有内容相同的请求;测试环境的压测请求内容完全一样。这些被当成重复给去重了,业务方投诉说"我明明要两次结果,只给了我一次"。
改进后的规则加入了时间窗口和调用意图。幂等键的构成是租户 ID、请求内容哈希、时间窗口序号,其中时间窗口默认三秒。也就是说三秒内内容完全相同的请求视为重复,超过三秒的视为新请求。同时提供了两个请求头:一个允许客户端显式传幂等键覆盖默认规则,另一个允许客户端声明"这是有意的重复请求,不要去重"。这套上线后误判率降到万分之零点三,主要残留在压测场景,我们建议压测流量带上不去重的标记。
流式请求的重试是最棘手的部分。非流式请求要么完整返回要么没返回,判断相对清楚。流式请求可能已经吐了一半内容然后断了,这时候重试意味着什么?从头再来会让客户端收到重复的前半段;不重试则内容不完整。
我们的处理分两种情况。如果断流发生在首字之前,视为未开始,可以安全重试,客户端无感知。如果已经吐出部分内容,不做整体重试,而是走续传逻辑:网关记录已经发送给客户端的 token 位置,向后端发起带上下文的续写请求,只把新增部分吐给客户端。续传做不到的场景(比如后端不支持续写),网关会发送一个明确的中断信号,让客户端自己决定重来还是接受截断结果。计费上,续传部分单独计量,不重复计算已经吐出的部分。
超时误判的问题我们做了分层超时。原来只有一个总超时,现在拆成连接超时、首字节超时、总超时三级。连接超时内失败的,说明请求大概率没到后端,可以放心重试;首字节超时说明后端可能已经在处理了,重试前先查一次执行状态;总超时的情况下已经产生了部分结果,走前面说的续传逻辑。这个拆分让"可安全重试"的判定准确率提高了很多,盲目重试的次数下降了七成。
执行状态查询这个能力是我们跟模型服务侧一起加的。后端在收到请求时会立即登记一条执行记录,网关可以按幂等键查询"这个请求现在是什么状态"。这个接口本身要求很高的可用性和低延迟,我们把它做成了独立的轻量服务,用内存存储加异步持久化,P99 在三毫秒以内。
计费回滚的设计上有个坑。最初我们的逻辑是发现重复后直接删掉多余的计费记录,运行两个月后审计提意见了,说计费数据不能物理删除,必须有完整的痕迹。改成了冲正模式:原记录保留,新增一条负向的冲正记录,两条相互关联,账单上展示净额但明细里能看到全过程。这个改动虽然是被审计推着做的,但事后看是对的,有几次客户质疑账单,我们能把完整链路摆出来,沟通成本低很多。
案例片段(已脱敏):幂等配置与请求头约定。
yaml idempotency: key_strategy: source_priority: [header_x_idem_key, auto_generate] auto_generate: parts: [tenant_id, body_sha256, time_bucket] time_bucket_sec: 3 opt_out_header: X-Allow-Duplicate # 值为 true 时跳过去重 inflight: mode: WAIT_FIRST # 相同键并发时挂起等待,不重复调用 max_wait_ms: 30000 result_cache: ttl_sec: 300 max_body_kb: 512 store: redis_cluster retry_policy: connect_timeout_ms: 800 # 超时 -> 可安全重试 first_byte_timeout_ms: 8000 # 超时 -> 先查执行状态 total_timeout_ms: 120000 # 超时 -> 走续传或中断 max_attempts: 2 streaming: resume_on_break: true resume_meter: SEPARATE # 续传部分单独计量 on_resume_unsupported: SEND_TRUNCATE_SIGNAL billing: duplicate_handling: REVERSAL # 冲正,不物理删除案例片段(已脱敏):一次首字节超时后的状态查询与决策日志。
``` req=8f2ca1 tenant=T019 idem_key=T019:9d3e:1770XXXX 12:04:11.203 gateway -> model attempt=1 12:04:19.208 first_byte_timeout (8000ms) 触发 12:04:19.211 query exec_state(idem_key) -> {state: RUNNING, started_at: 12:04:11.288, tokens_out: 0} 12:04:19.211 decision = WAIT (后端在跑,不重试) 12:04:23.870 first byte received, ttft_actual = 12667ms 12:04:31.402 completed, tokens_out=612, 单次计费
对照旧逻辑: 8s 超时即重试 -> 后端并发处理两次 计费 2 次,客户端收到先返回的那份,另一份丢弃 ```
方案全量上线五个月,数据如下。
重复执行率从万分之七点四降到万分之零点二。剩下的这部分主要发生在网关自身节点故障切换的极端场景,缓存状态未及时同步导致,我们评估后认为再优化的性价比不高。
重复计费笔数从月均四千五百多笔降到月均一百二十笔左右,且这一百二十笔全部被对账环节自动识别并冲正,没有流到客户账单上。客户发起的计费争议从月均二十三起降到一起,那一起还是客户自己算错了。
重试成功率从百分之六十一提到百分之九十四点三。提升的原因不是重试变聪明了,是不该重试的不再重试,分母变干净了。盲目重试次数下降百分之七十一,这部分省下的算力按月折算大概是三张卡的量。
对账差异从原来的百分之零点八三降到百分之零点零一。财务那边处理计费争议的人力从每月约十五小时降到不足一小时。
流式续传的成功率百分之八十九点六,剩下的是后端不支持续写或上下文已失效,走了截断信号。状态查询接口日均调用约一万四千次,P99 延迟二点七毫秒。误判去重率万分之零点三,主要来自压测流量。
文中数据为脱敏后的复盘口径。
幂等这件事,最容易出错的地方不是技术实现,是"什么算重复"的定义。我们第一版用内容哈希直接去重,逻辑上无懈可击,业务上完全站不住,因为内容相同不等于意图相同。加了时间窗口和显式声明之后才可用。做幂等设计时,先跟业务方把"重复"的定义谈清楚,比急着写代码重要。
超时不等于失败,这一点在分布式系统里是老生常谈,但在 AI 网关这个场景下影响被放大了,因为单次调用的成本高、耗时长。分层超时加执行状态查询这套组合,本质上是把"我不知道后端怎么样了"变成"我去问一下后端",多一次查询换掉一次昂贵的重试,非常划算。
流式场景要单独设计,不能套用非流式的逻辑。我们最初想偷懒复用,做到一半发现根本走不通。续传方案实现起来有点绕,但它是唯一能同时保证内容完整和不重复计费的路径。
涉及钱的操作不要物理删除。审计提出的那个要求当时觉得麻烦,后来发现冲正模式在客户沟通上帮了大忙。任何计费相关的修正都应该是追加记录而不是修改历史,这条规则值得作为默认设计。
给业务方留一个"我就是要重复"的出口很有必要。压测、AB 对比、多样性采样,这些场景下重复是有意为之。没有出口,业务方就会加随机字符串绕过去,反而破坏可观测性。
还没解决的是跨网关节点的强一致性。现在用的是缓存共享,极端情况下节点切换有短暂窗口。做成强一致要引入分布式共识,延迟代价太大,客户权衡后接受了当前的万分之零点二。这个决定我认同,工程上最后那一点点完美是最贵的。