先说个背景。去年我在维护一个智能客服系统时,发现生产环境的故障有一大半不是模型幻觉,也不是底层模型服务宕机,而是API编排层在压力下先撑不住了。一次简单的多轮对话会依次触发意图识别、知识库检索、工具调用、大模型生成,中间任何一步超时或失败,整个用户请求就像多米诺骨牌一样重试、堆积、雪崩。那段时间我几乎每天都在看链路图,最后把API编排层当成独立的高可用系统工程重修了一遍,才算把故障率压下来。
这个话题适合所有正在做AI原生应用、智能体、Copilot类产品的开发者。AI原生应用不是简单地把大模型接口包一层HTTP路由,它的核心逻辑都在编排层里——怎么调度模型、怎么调用外部工具、怎么维护会话上下文、怎么做降级和兜底。标题里说的"API编排"指的就是这个大脑。这篇文章会围绕高可用这个目标,讲清楚编排层到底在编排什么、高可用指标怎么定、超时重试幂等怎么设计、状态和降级怎么落地,以及最后部署演进时容易踩的坑。
1. AI原生应用的API编排,到底在编排什么?
1.1 和传统API网关的本质区别
很多团队一开始会习惯性用之前的API网关思路来做AI应用的编排层——挂一堆路由规则、鉴权、限流,然后把请求转发给模型接口。等你真正接到多轮对话、Agent工具调用、流式输出之后会发现,它和传统API网关完全是两码事,传统网关解决的是"请求转发到正确服务",而AI编排解决的是"如何在一个请求里动态决定调哪几个服务、以什么顺序调、参数如何组装、结果如何合并、出错时怎么办"。
传统网关的路由表是静态的,而AI编排的路由是由模型根据用户输入实时生成的。同样一句话,今天走检索加生成的路径,明天可能就变成直接调用外部订单接口。这意味着你不能像配Nginx location一样提前把路径写好,编排层必须有能力执行一个动态计划,并且处理计划在执行过程中发生的变化。
另外,传统API网关是无状态的,但AI编排天然有状态。对话上下文、工具调用结果、中间步骤的临时数据都要在编排过程中被管理。一旦编排层崩溃,用户可能已经输入了三轮信息,重启后很难再回到原来的状态。这就是为什么高可用设计不能照搬旧方案。
1.2 编排对象的四种基本形态
我把日常会遇到的编排对象分成四类,这四类对应着不同的高可用策略:
第一类是大模型调用。这是最常见的编排目标,包括主模型、辅助模型、评测模型的调用。它们的特点是耗时长、价格贵、结果不可控,而且依赖外部网络。对这类对象,编排层需要处理超时、重试、成本控制、响应解析,尤其是流式响应下的中间状态中断问题。
第二类是外部工具调用,也就是常说的function calling或工具调用。比如查天气、查库存、下单、发通知,这类调用通常有真实的业务副作用。它们的特点是延迟波动大、可能产生下游业务影响,而且不是每次调用都成功。编排层在调用前要校验参数,调用后要处理业务错误,还要考虑这个工具失败后能否用另一个工具替代,或者干脆跳过。
第三类是知识库检索。RAG类的应用会把用户问题向量化后去向量库召回,再拼进Prompt送给模型。这类调用的特点是与上下文强相关,检索结果的质量直接影响模型回答。高可用设计上要考虑检索超时后的降级方案,比如降级为关键词搜索,或者强制使用缓存中的历史结果。
第四类是人机协同流程。比如审稿、审核、审批这类场景,AI生成初稿后需要人工确认,编排层要负责把流程挂起、等人工结果、再继续后面的步骤。这类流程可能持续几十分钟甚至几天,编排层的状态管理能力在这里接受考验。
这四类对象混合在一起,才是AI原生应用的完整编排场景。做高可用时,不要试图用一个通用方案包打天下,我建议先画出自己应用里有哪几类节点,每一类分别设计超时和降级策略,再考虑怎么统一治理。
2. 先把"高可用"翻译成可量化的设计目标
2.1 AI场景的可用性指标,和传统SLA哪里不同
传统API的可用性指标非常清晰:QPS、错误率、P95/P99延迟、可用性百分比。但在AI原生应用里,这些指标如果不加转换,会带来很大的误导。举个例子,你的模型网关整体错误率只有0.5%,看起来非常健康,但某个用户的单次完整任务里包含了20个模型调用步骤,那么整个任务级别的失败概率大约是1减去0.995的20次方,约等于9.5%。也就是说,即使每个环节都很稳,用户感知到的成功率却非常差。
所以我在实际设计中,除了关注单次API调用的指标,还会额外盯任务级成功率(也叫流程级成功率)。从用户角度看,只有完整走完一次智能问答、一次下单助手流程、一次文档生成流程,才算成功。这个指标的背后直接反映编排层的韧性——节点失败后能不能绕过,步骤中断后能不能续跑。
另一个差异在于Token消耗和成本。传统接口失败的代价是产生一次5xx错误,而AI编排失败的代价还包括已经烧掉的Token成本。比如模型生成到一半超时,前面生成的内容全部作废,重试时还要重新烧一遍前置调用的成本。因此,高可用设计不只看错误率,还要看"无效Token比例",这是个很实操的成本指标,我后面会再讲。
2.2 SLO、错误预算与成本预算
做高可用不是把每一个环节都做到100%,而是定义好可接受的下限。我给团队定的做法是:在编排层建立两个预算——错误预算和成本预算。
错误预算跟SRE的思路保持一致。例如,设定任务级成功率SLO为99.5%,也就是一个月内10000个任务最多允许50个失败。一旦失败数量逼近限额,就要主动触发限流或降级,防止超出用户容忍底线。这个简单机制能让"高可用"从一个抽象愿望变成一个可执行的门禁条件。
成本预算是我在AI项目中特别强调的。给每个用户请求设定一个Token花费上限,例如一次普通问答最多10000 Token,一旦编排过程中发现可能超支,就触发策略:压缩历史上下文、切换更小的模型、或者直接停止工具调用链,只输出一个简化答案。听起来很具体,但它和可用性紧密相关——不控制成本,就无法在高峰期放开并发,无法放开并发,可用性自然受制约。
2.3 一个可以直接套用的指标定义模板
下面是我们在模拟项目X里实际使用的指标清单,你可以根据自己业务调整:
| 指标名称 | 定义 | 建议阈值 | 说明 |
|---|---|---|---|
| 任务级成功率 | 完整流程成功数 / 总请求数 | ≥99.5% | 最核心的体验指标 |
| 编排层自身错误率 | 非上游错误导致的编排异常 | ≤0.1% | 反映编排层代码质量 |
| 单次任务P95耗时 | 从请求进入到最终完成 | ≤5s | 超过则需检查依赖链路 |
| 无效Token占比 | 因超时、中断、重试浪费的Token / 总Token | ≤3% | 过高说明超时与重试策略不当 |
| 缓存命中率 | 命中缓存请求数 / 总请求数 | ≥40% | RAG场景重点指标 |
| 降级触发次数 | 当周降级发生次数 | 有告警即可 | 用于复盘瓶颈 |
这套指标不一定全,但能让你每次讨论高可用时有一个统一的度量基准。高可用不是一个最终状态,而是用这些指标不断校准的过程。
3. 编排层的第一道防线:超时、重试与幂等
3.1 为什么"超时设长点"会雪崩
我见过不少团队在编排层会把模型调用超时设成60秒、90秒,理由是"模型生成本来就慢,设短了老超时"。这个直觉很危险。假设上游模型服务的平均耗时是3秒,P99是15秒,你的编排层把超时设为30秒,那么正常情况下大多数请求都能通过。但如果模型服务因为负载过高开始变慢,P99从15秒涨到40秒,超时时间30秒就会变成一个过滤闸门,所有请求都要等30秒才失败,而新的请求还在持续进入,编排层的工作线程会被这些注定失败的请求占满。线程池耗尽后,连健康检查都做不了,整个服务直接雪崩。
我的经验是,超时时间必须和上游的延迟分布强相关,而不是拍脑袋。先连续观测一段时间,统计上游P50、P95、P99延迟,把超时初步设为P95的一到两倍,同时限制最大超时不超过20秒(除非你的业务真的需要长任务)。运行一段时间后再根据实际错误率校准。宁可让部分请求提前失败,也不允许大量请求堆积占死线程池。
3.2 重试的账要算服务端
HTTP调用重试是很自然的动作,但在LLM调用里,重试的成本比普通API高得多。一方面模型服务按Token计费,每次重试都在烧钱;另一方面,用户看到的是延迟成倍增加。我处理过的案例里,有一次客服系统的外部工具调用失败后,编排层自动重试了三次,结果每次重试都在等待超时,用户端30秒没有任何输出,最后还额外消耗了约3000 Token。
所以重试策略不能一刀切"失败三次+指数退避"。我现在的规则是:只有在上游明确返回网络错误、限流或5xx时,且错误信息里没有"不要在业务侧重试"的语义时,才允许重试。对于超时类错误,第二次重试的时间代价用户通常接受不了,需要直接走降级路径。同时我会区分幂等重试和业务副作用重试,涉及到下单、扣款这类动作时,重试必须先把幂等键带上,否则宁可失败返回人工介入。
多模型场景下还有个思路是重试时切换供应商,比如主模型服务超时后直接切换到备用的模型服务,而不是重试同一个已经拥堵的实例。实测下来,这类"跨实例重试"的成功率往往比原地重试高一倍。
3.3 幂等键与去重表:从源头控制副作用
前面提到工具调用有真实副作用,这是AI编排高可用里最容易被忽略的一环。比如你的助手帮用户查库存、锁定库存、生成订单,如果编排层因为网络问题对"创建订单"这个工具重试,用户可能会收到两笔相同订单。这里必须引入幂等机制。
我的做法是在编排层的入口为每个用户任务生成一个全局唯一的requestId,并以requestId+工具名+输入参数哈希值作为单次工具调用的幂等键。调用带副作用的工具时,这个幂等键会随请求一起传给下游。下游如果发现同一个幂等键已经处理过,直接返回上次的结果,不再重复执行。如果下游不支持幂等,编排层就要自己维护一张去重表,记录已完成的调用签名和响应结果。
这张表的高可用本身也需要注意,不能用本地内存存——进程重启就丢了。我会把去重表放在Redis里,带TTL,例如保留24小时,恰好覆盖业务流最长周期。为了让,Redis不可用时不至于阻塞主流程,去重操作要做容错:宁可放行新请求,也不要因为查不到去重记录就拒绝所有重试。这在分布式系统里是典型的"可用性优先于一致性"取舍。
3.4 一个经过验证的重试调度示例
下面是我在项目里用过的一段简化伪代码,展示超时与重试如何协作:
async def call_llm_with_resilience(llm_client, request, eager, request_id): # 第一次尝试 for attempt in range(2): # 最多重试1次 try: resp = await llm_client.chat( request, timeout=eager.timeout_ms, # 短超时 headers={"X-Request-Id": request_id} ) if resp.status == 429 or resp.status >= 500: # 只对明确的系统错误重试 if attempt == 0 and eager.allow_retry: await asyncio.sleep(0.5 * (attempt + 1)) continue raise UpstreamUnavailableError(resp.status) return resp except asyncio.TimeoutError: # 超时不重试,直接降级 raise LLMTimeoutError() # 降级逻辑:切换模型供应商或返回安全兜底 fallback_resp = await fallback_client.chat( request, timeout=eager.timeout_ms, headers={"X-Request-Id": request_id} ) return fallback_resp注意我在第二次调用时直接切换到备用的fallback客户端,而不是重试同一个上游。同时把超时时间保持在一个固定的合适值附近,不因为重试而增加。这样单次任务的最坏延迟可控,用户感知更稳定。代码里的X-Request-Id就是幂等的一条边,下游和日志系统都能靠它串联全链路。
4. 状态管理与降级落地:从会话上下文到模型切换
4.1 状态放哪:无状态编排加外部状态存储
AI原生应用几乎绕不开状态。最简单的场景,多轮对话需要累积历史消息;复杂一点的场景,Agent执行过程中可能已经完成了三步工具的调用,第四步失败后需要从第三步的结果继续续跑。这些状态如果放在编排服务的内存里,一旦服务被重新部署或者扩容,所有进行中的流程直接中断。
我踩过这个坑:最初单机部署时,所有会话上下文都存在进程本地Map里,后来为了扛流量,把服务水平扩展到三副本,结果用户刷新一下就被路由到另一个副本,上下文全部丢失,那段时间的投诉率非常高。
后续的处理方案很明确:编排服务本身保持无状态,所有上下文、步骤状态、中间结果都放到外部状态存储里。最常用的方案是把会话上下文存Redis,按会话ID聚合。步骤执行状态则设计成一张流程状态表,每条记录包含requestId、节点名、输入摘要、输出摘要、耗时、错误信息、状态(pending/success/failed/skipped)。这样即使编排进程挂掉,新启动的实例也能通过requestId从状态表里恢复流程。
有人会担心Redis的性能和持久化,实际用下来,带AOF持久化的Redis足够支撑绝大多数场景。至少比内存方案稳定很多。如果单Redis不够,可以做分片或引入带副本的集群,但那是另一个话题。
4.2 降级梯队:模型降级、流程降级、内容降级
高可用系统的核心能力之一,是在异常时让系统以"可接受的最低服务水平"继续运行。我在AI编排里把降级设计成三个梯队,按影响范围从小到大排列。
第一梯队是模型降级。主模型不可用或超时时,切换到备用模型。比如主模型超时后,先用一个参数较小的快速模型顶住。实测中模型降级能把可用性从99%抬到99.8%左右,且用户几乎无感知,只是回答深度略降。
第二梯队是流程降级。某些工具调用失败时,不直接中断整个任务,而是跳过该工具,改用其他路径完成目标。比如查天气的工具失败,就改为直接检索天气网站的公开摘要;下单失败时,改为生成下单链接让用户自行点击确认。流程降级要求你在设计编排图时,预先为关键节点准备"替代路线"。
第三梯队是内容降级。这是最保守的兜底,当所有模型都不可用时,返回固定话术或缓存的历史答案。内容降级虽然解决不了问题,但至少让用户知道系统在尽力,而不是白屏或连接超时。对用户体验的伤害,要远小于直接抛500错误。
编排层实现降级梯队时要注意,降级动作本身要可观测,比如在返回的头里加一个X-Fallback-Reason,方便后续从日志中统计降级发生频率,避免某条降级路径被默默触发了几十万次却没人知道。
4.3 上下文窗口压缩与Token预算策略
上下文管理对高可用影响很大,因为它直接跟成本和可用性挂钩。每当对话变长,Prompt里的历史消息会持续膨胀。模型处理长上下文的时间呈超线性增长,Token费用也在涨,接口超时风险同步上升。很多线上故障其实是"上下文过长导致生成阶段超时",而不是模型服务本身故障。
所以编排层必须有一个上下文压缩模块。我常用的策略有三种:
一是按时间衰减。超过30分钟的历史消息只保留摘要,不保留原文。做摘要的模型调用本身要控制次数,通常每十轮才做一次摘要。
二是按重要度裁剪。把工具调用结果中的长文本做截断,只保留关键字段,比如查询商品只保留名称、价格、库存,不保留完整JSON。这能显著减少Token数,又保留足够的信息。
三是预算硬限制。为每次请求设定Token上限,例如主模型上下文8K,工具结果解析后如果超限,优先丢弃中间最古老的工具结果,而不是丢弃用户最近一轮的输入。这类策略需要结合业务场景微调,但原则是"宁肯模型少做一些推理,也不能让它因超限而崩溃"。
实测下来,加上预算硬限制后,单次任务P95延迟下降了大概30%,无效Token占比也从5%降到了2%出头。这对提升整体稳定性直接有效。
5. 可观测性:把每次编排变成一条可复盘的时间线
5.1 编排层需要记录哪些关键事件
如果你的系统出现了一次用户投诉,说"机器人回答到一半就不动了",而你手里只有应用网关的访问日志,你会非常抓狂。请求是哪一步开始失败的?调用了哪个模型?烧了多少Token?当时上下文里有什么?这些问题没有正确的观测数据,就无法定位。
在编排层,我最关心四类事件:请求流入事件、每一次外部调用事件、分支决策事件、异常和降级事件。每一类都必须带requestId、时间戳、耗时、节点名、输入摘要、输出摘要、错误码。尤其是外部调用事件,一定要记录上下游用了多少毫秒、返回的状态码、是否重试、重试了几次。没有这些信息,后续很难判断到底是模型服务慢还是编排逻辑慢。
5.2 Trace与业务事件结构化结合
技术圈里大家习惯用OpenTelemetry这类标准Trace来记录Span调用链。在AI编排场景里,我建议在Trace之外,额外打一套"编排业务事件",以JSON格式落到日志中心。原因是标准Trace对技术栈友好的,但对业务语义不友好——协作平台里的人可能看不懂一个Span到底代表"调了工具A"还是"切了模型B"。
实际做法是让Trace负责底层的调用链关系,比如每个节点的耗时、上下游依赖,而业务事件负责表达"这一步做了什么决策、为什么这样做"。例如:
{ "event": "tool_invocation", "requestId": "7f3c4a...", "tool": "order_query", "argsHash": "ab12cd34", "resultStatus": "success", "durationMs": 452, "fallbackTriggered": false, "tokenCost": 0, "timestamp": "2025-01-15T10:00:12.123Z" }这样每个用户请求都形成了一条完整的时间线。某个环节发生了降级、重试、超时,一眼就能看清楚。排查线上问题时,我通常先看业务事件列表,确定异常点,再跳到Trace里看更细的调用链,效率比从前翻日志高很多。
5.3 一次故障排查的完整链路
举一个实际发生过的例子。某天下午,智能助手突然大面积报"回答超时"。我们第一反应是怀疑模型服务故障,拉出事件时间线后发现,从入口进来的请求全部阻塞在"知识库检索"这个节点上,等待时长高达8秒,而后面的模型调用还没开始。
再展开业务事件,发现向量检索的并发在那一刻到达了连接池上限,大量请求在排队等待连接。进一步查Trace,确认不是模型服务问题,而是之前一天我们调整了知识库集合的索引配置,导致检索慢了一个数量级。整个过程从接到报警到定位根因,花了不到20分钟。如果没有编排层的业务事件和Trace,可能要到第二天才能定位。
这个案例给我的教训是:AI编排层的可观测性不是可选项,而是和业务逻辑一样重要的核心模块。在高可用系统里,任何一个节点故障都应该在观测面板上能被迅速定位。建议团队在系统建设初期就为每个关键节点埋好日志和指标,不要等出了事故再补。
6. 部署与演进:从单机编排到可扩展架构的坑
6.1 无状态水平扩展前的四个改造点
很多编排服务早期都是单机部署,没有会话粘滞,请求随便路由也能正常跑。当你打算水平扩展到多副本时,会发现有几个潜藏的问题必须提前处理。
第一,会话上下文已经挪到外部存储之后,代码里要彻底清理所有本地内存缓存,尤其是模型调用结果缓存。如果有本地缓存,节点A的缓存命中可能跟节点B不一致,表现为同样的请求在不同节点上手感不同。
第二,在途任务的处理方式要明确。编排层接收到一个请求后,如果请求在节点A上执行到一半,节点A被滚动更新掉,这个任务是被中断还是被转移到节点B?我建议把执行中的任务设计成可恢复的,只要状态表里有完整步骤记录,新节点就可以从断点续跑。如果做不到续跑,那至少要优雅终止且返回可理解的错误信息,不要让用户看到一个永远转圈的界面。
第三,分布式锁要跟着走。某些工具调用不允许并发(比如同一个用户的连续操作),单机部署时用进程内锁就够了;多副本后必须改为Redis分布式锁或数据库锁。否则两个节点同时帮同一用户操作同一个外部系统,会产生严重的数据一致性问题。
第四,限流要从单机限流改成分布式限流。每个节点的限流器如果只统计本机流量,整体额度就会变成节点数倍。我见过某团队在扩容到5个节点后,把原本设计的上游QPS限制撑大了5倍,导致上游告警。比较省事的做法是,在编排层前面统一进入一个分布式限流中间件,或者用Redis做令牌桶。特别在限流策略涉及成本预算时,分布式限流几乎是必须的。
6.2 连接池与限流的配合
AI编排服务通常要跟多个上游打交道:模型服务、知识库、外部业务API、Redis。每个上游的连接池大小都需要根据实际并发量去调节。我遇到过的问题是连接池配置过小,比如默认给模型服务分配了100个连接,但业务高峰期需要300个并发,结果大量请求在连接池排队。这个时间又被统计进上游耗时,导致上游P99延迟飙升,编排层误判为上游故障,触发了很多不必要的降级。
正确的做法是,不要把连接池配置和超时配置割裂开。连接池本质上是阻塞资源,连接满了,超时时间再长也是白等。我的经验是,连接池大小至少要能覆盖该路依赖的峰值QPS乘以平均耗时,再加上一定余量。比如模型调用峰值QPS是50,平均耗时3秒,那连接池至少需要150个连接才不阻塞。当然这只是粗略估算,真实还要考虑上游的连接并发限制。
同时,分布式限流的阈值也应和连接池大小匹配,避免限流允许的请求量超过连接池能承载的量。否则限流还没生效,连接池先被打满。
6.3 灰度发布时的编排兼容问题
编排层和普通服务不同,它经常要面对"新编排逻辑"和"旧上下文状态"同时存在的情况。发布新的编排规则后,如果有一个在旧版本里已经执行了一半的任务,被路由到新版本的节点上,新逻辑可能认不出旧状态表里的某些节点ID或字段结构。这个问题在灰度发布时尤其突出。
方案之一是做好状态增量演进,状态表里加版本号字段。新版本读取旧版本数据时,先做一层兼容解析,不认识的字段忽略,缺省字段给默认值。方案之二是在发布期间保留一小部分旧节点承接在途任务,等到所有在途任务自然结束再下线旧节点。方案之三是深度优先的"任务主机亲和":在任务开始执行时绑定一个编排版本标识,后续步骤尽量路由到同版本的节点上处理,除非节点不可用。
我在模拟项目X里实践下来,第三种方案最实用,也让灰度风险最小。简单说,状态表里写清楚"该任务使用的编排版本号",路由层发现同一任务要尽量调度到同版本实例。这虽然牺牲了一点负载均衡的均匀性,但换来的稳定性非常值得。
6.4 对高可用的一点个人感受
整套系统重构完以后,我的一个明显体会是,AI编排层的高可用,更多在于"怎么失败得优雅",而不是"怎么保证永远不失败"。模型服务会有波动,外部工具有不可用的时候,网络也会有抖动,这些都是事实。编排层能做的,是通过合理的超时、重试、幂等、状态存储、降级梯队、可观测性设计,让每一次失败都呆在可控范围内,不让单点故障演变成系统性雪崩。
还有个心理层面的事也想分享:不要追求把每个依赖的SLO都调到99.999%,那既不现实也极昂贵。把注意力放在编排层自身的韧性上——失败时能否快速恢复,降级是否无缝,排查是否能20分钟内定位。对我这种实际干活的人来说,这种韧性比一个漂亮的数字更让人安心。