详细记录 AI Agent 生产发布血泪教训:工具描述改写导致调用量翻倍、P95 延迟上涨 38%,并给出流量分配、自动回退和成本上限的完整 runbook。
上周一我把第 9 部分的物流工具提示词上线到了 5% 的线上流量。它在配对门禁中以 24 比 7(9 个平局)获胜,工具纪律差异很干净,金额链路案例人工复查也没问题。我信心满满地去吃午饭了。
下午 6 点 42 分,金丝雀仪表盘给出了相反的结论。P95 延迟上升了 38%。每个会话的工具调用次数从 3.1 次攀升到 5.4 次。我引以为豪的重新措辞的工具描述教会了智能体每轮调用两次物流工具,而在真实对话中——运行时间远比我的 40 个测试用例长得多——每多一次调用都会让等待时间翻倍。九分钟内我把流量分桶切回零,到 7 点 15 分各项指标恢复基线。
第 8 部分的 40 个案例数据集不可能发现这个问题。它的对话记录本身就是按短对话设计的。第 9 部分的配对评判器也不可能发现它,因为它评判的是两个响应,而不是整个会话的成本。只有流量能发现问题,而流量只有在你先为其开通一条通道时才会和你对话。
这一部分是我在第 9 部分末尾承诺的生产运行手册:金丝雀流量分割、模型降级时的自动 fallback,以及防止提示词回归变成账单回归的成本上限。智能体与第 1 到第 9 部分相同:九个工具、对话记忆、监督器,以及人工介入的结账门禁。我使用 Spring Boot 和 Spring AI 构建生产级 AI 智能体已超过一年,以下所有数字都来自我实际运行时的部署。
为什么上线规范现在是安全层
本系列中的每个门禁都活在流量之前。第 6 部分证明代码无 bug,第 8 部分证明在固定数据集上答案质量良好,第 9 部分证明一项变更在受控对比中优于其前身。没有任何一个能证明变更经得住真实用户的考验,因为真实用户会做出你的数据集从未设想过的事:他们用孟加拉语和英语混写消息,他们问三个月前的订单,他们和智能体争论第 4 部分缓存的价格。
更广泛的行业也在朝同一方向演进,而这反而拉大了差距。Anthropic 测量发现 Claude Code 用户对权限提示的批准率达 97%,该比例表明大多数用户无需逐一审查每条命令就直接点击通过;在包含 1053 名测试者的一项研究中,其自动模式捕获了 89% 的植入危险命令,而人类仅捕获了 13.6%。据其自身公告,从 8 月 14 日起,Anthropic 将把自动模式设为 Pro、Max 和 Team 计划新会话的默认选项。无论你对这一权衡作何感想,它描述的是同样的转变:智能体在每次调用时的人类监督在减少,因此发布周围的机制决定了什么能触达用户,而不是 IDE 中的审查。如果你的智能体无人值守运行,你的金丝雀和你的 fallback 就是审查者。
金丝雀分割:路由一小部分流量,观察它,信任它
这个原则是有意为之的平淡无奇。智能体的两个版本同时存在,都与生产智能体构建自相同的组件。路由器将一小部分对话发送到候选版本,其余发送到基线版本。你将候选队列与基线队列进行对比观察,只有当候选版本不再落后时才晋升。
Spring AI 为你提供了两个客户端。ChatClient 参考文档展示了我使用的模式:自动配置的原型 ChatClient.Builder 为每个配置生成一个 bean,你通过名称和 @Qualifier 注入它们。
@Configuration
public class AgentRoutingConfig {
@Bean("baselineAgent")
ChatClient baselineAgent(ChatClient.Builder builder) {
return builder
.defaultSystem(SYSTEM_PROMPT_V1)
.build();
}
@Bean("candidateAgent")
ChatClient candidateAgent(ChatClient.Builder builder) {
return builder
.defaultSystem(SYSTEM_PROMPT_V2)
.build();
}
}
两个 bean 共享同一个工具注册表、同样的记忆连接方式,以及与第 1 到第 9 部分生产智能体相同的 advisor。唯一区别是系统提示词,而在这个智能体中工具描述位于系统提示词内部,所以像第 9 部分物流提示词这样的工具描述变更是系统提示词变更。如果你在两个客户端之间同时改了两样东西,金丝雀就无法告诉你是哪一个移动了指标。
路由器是规范所在之处。重要的细节是粘性:同一对话必须始终保持在同一版本上,因为智能体的记忆(第 2 部分)是按对话且按版本划分的。一位客户问了问题,从候选版本得到了答案,然后刷新页面却打到了基线版本,这将导致他在对话中途体验到一个不同的智能体。所以我按对话 ID 的哈希值来路由,而不是逐条消息地抛硬币。
@Service
public class CanaryRouter {
private final Map<String, ChatClient> agents;
private final CanaryProperties props;
public CanaryRouter(Map<String, ChatClient> agents, CanaryProperties props) {
this.agents = agents;
this.props = props;
}
public ChatClient forConversation(String conversationId) {
int bucket = Math.floorMod(conversationId.hashCode(), 100);
if (bucket < props.candidatePercent()) {
return agents.get("candidateAgent");
}
return agents.get("baselineAgent");
}
}
在 application.properties 中设置 agent.canary.candidate-percent=5,重启即可切换分割比例而无需重新部署。CanaryProperties 是一个小的 @ConfigurationProperties(prefix = "agent.canary") 持有类,只有一个 int 字段 candidatePercent(),所以分割比例来自配置而非代码。这是另一条规则:阶梯是 5、10、25、50、100,每步至少维持一天,每步都是配置变更而非代码变更。代码变更会重启实验。只有当队列数字保持平稳时我才跳过某一级。
候选队列是一个队列,而非样本。按同一时间切片对比候选版本与基线版本:错误率、P95 延迟、每个会话的工具调用次数、拒绝率,以及从实时日志中采样的第 8 部分指标。正是队列对比在物流提示词事件那天救了我。夜间测试套件要到第二天早上才会标记工具纪律下降。金丝雀在下午 6 点 42 分就标记了——5% 上线几小时后——因为候选版本的 P95 已偏离基线版本,幅度达到了队列报告设计用来捕获的程度。
回滚是自动的,本质上是一次配置翻转。我的触发条件:错误率超过基线 1 个百分点且持续 10 分钟,P95 超过基线的 1.5 倍且持续 10 分钟,或任何金额链路对话(结账、退款、物流)未通过第 8 部分审查。任何触发条件都会将 candidate-percent 设为 0 并呼叫我。我不想在凌晨 2 点被叫醒去做判断;我希望在决策做出后才被叫醒去调查。
物流提示词被打回重做。收窄后的描述——说明该工具解析地区和配送预计时间、每轮调用一次——重新跑了第 9 部分门禁,然后再次爬上阶梯。花了五天才达到 100%:5% 两天,10% 一天,25% 一天,然后直接全量,跳过 50% 因为队列数字保持平稳。流量是最终的审查者,但它一次只审查一个切片。
Fallback:在出问题的模型下存活
金丝雀保护你免受自己的变更影响。Fallback 保护你免受其他一切影响:提供商宕机、上游更新后模型变差、峰值时段的速率限制。我将其分为两类失败,因为它们需要不同的机制。
硬失败是异常:5xx 响应、超时、速率限制。解决方案是模型的装饰器。Spring AI 的 ChatModel 接口很小:call(Prompt) 返回 ChatResponse,stream(Prompt) 返回 Flux<ChatResponse>。这个接口就是接缝点。我用备用模型和一个小型的熔断状态来包装主模型:连续三次失败打开熔断器 60 秒,期间每个请求都发到备用模型,一次成功的探测关闭它。
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import java.time.Duration;
public class FallbackChatModel implements ChatModel {
private final ChatModel primary;
private final ChatModel backup;
private final CircuitState circuit = new CircuitState(3, Duration.ofSeconds(60));
@Override
public ChatResponse call(Prompt prompt) {
if (circuit.isOpen()) {
return backup.call(prompt);
}
try {
ChatResponse response = primary.call(prompt);
circuit.recordSuccess();
return response;
} catch (RuntimeException ex) {
circuit.recordFailure();
return backup.call(prompt);
}
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
if (circuit.isOpen()) {
return backup.stream(prompt);
}
return primary.stream(prompt)
.onErrorResume(ex -> {
circuit.recordFailure();
return backup.stream(prompt);
});
}
}
熔断状态是一个小小的计数器类,它将整个策略编码其中:失败次数递增,达到三次失败则打开熔断器,成功则重置计数器。
// imports: java.time.Duration, java.time.Instant
class CircuitState {
private final int failureThreshold;
private final Duration openDuration;
private int failures;
private Instant openedAt;
CircuitState(int failureThreshold, Duration openDuration) {
this.failureThreshold = failureThreshold;
this.openDuration = openDuration;
}
boolean isOpen() {
return openedAt != null
&& Instant.now().isBefore(openedAt.plus(openDuration));
}
void recordSuccess() {
failures = 0;
openedAt = null;
}
void recordFailure() {
failures++;
if (failures >= failureThreshold) {
openedAt = Instant.now();
}
}
}
Spring AI 本身没有自带熔断器,所以这个包装类是务实的选择;Spring Cloud Circuit Breaker 集成则提供了注解驱动的替代方案。第三部分有一个流式处理的注意事项:如果主模型在流式输出的中途失败,用户已经看到了一部分文本,此时切换模型会让答案变得更差,而不是更好。我的备用方案只在请求开始时触发。中途失败则使用已有的内容完成输出,第四部分的观测层会记录这次截断。
静默降级更糟糕,因为根本不会抛出异常。一个模型可以在不抛出任何异常的情况下明显变差:更多的拒绝、更少遵循工具调用、 hallucinated 价格(幻觉般的价格)。唯一的检测手段是测量,这就是第八部分存在的意义。每夜测试工具是最终的裁决者,我在此基础上加了一条规则:连续两个夜晚指标低于阈值,触发与熔断器相同的自动操作,即切换配置将流量导向备用模型,并通知我。第八部分说过一个夜晚是噪声,三个夜晚是信号。两个夜晚是针对降级的折中方案,因为降级的模型每多运行一夜,就会失去客户并烧钱。
让我下定决心那次故障是:某提供商在周二下午 2:14 经历了一波 5xx 错误。大约 40 秒后,熔断器打开——那时已有三个请求失败。流量在备用模型上运行了 26 分钟,直到提供商恢复。那个时段的错误率保持在 0.4%,而同一时段的上一周(没有备用方案)遭遇同类事故时错误率为 2.1%。客户感受到的是回答稍慢了一点,但没看到报错。
成本上限:阻止 prompt 回归演变成账单回归
金丝雀发布捕获了待发布 prompt 的延迟,在它触达所有用户之前。成本上限的存在理由相同,因为一个对延迟来说太细微的回归,仍然可能掏空预算。我的经验法则:如果一个 prompt 变更让每次对话增加一次工具调用,按该智能体现在的流量处理,会使每日模型账单移动超过 20%。账单是最后才会被人检查的数字,却是财务第一个会来问的。
Spring AI 在每个响应上追踪 token 使用量,usage 处理参考文档展示了精确的访问模式:
ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
Usage usage = response.getMetadata().getUsage();
long turnTokens = usage.getTotalTokens(); // 本次调用的 prompt + completion
Long cacheRead = usage.getCacheReadInputTokens(); // 当提供商没有缓存时为 null
三层上限建立在这一行代码之上,它们是"惊喜账单"与"受控账单"之间的差别。
每轮上限。maxTokens 选项已经限制了单次 completion,但昂贵的是输入 prompt:有了对话记忆,每一轮都会重新发送历史。要关注的每轮数字是总 token 数,而不是 completion token 数。
每对话上限。长对话是成本失控的地方。我为每个对话维护一个运行中的总量,当它超过 6000 token 时,智能体切换到便宜模型来完成该对话的剩余部分——当对话处于付费路径时,也可以转交给人工。得体的交接是产品决策,第七部分的审批门给了你挂载它的钩子。
每日全局上限。一个数字:过去七天日均账单乘以三。超过则将所有流量切换到便宜模型,直到当日结束,并通知值班人员。上线第一个月里它触发过两次,两次原因相同:一个 prompt 变更让智能体变得更健谈、因而更啰嗦,但对所有人正在监控的指标毫无影响。评委给答案打了更高的分。上限给它们的评分是贵了 31%。最终账单只超支了 8%,因为上限在中午就截住了。
Prompt 缓存是第二根杠杆,但它是提供商相关的。usage 参考文档列出了哪些提供商会报告缓存读取:Anthropic 和 OpenAI 读取缓存输入,Google Gemini 也是,而 DeepSeek、Mistral 和 Ollama 不报告任何东西。对于长对话,缓存读取数才是告诉你重复历史是否真的便宜的那个数字。如果你通过 Ollama 运行本地模型,没有缓存指标可以依赖,所以每对话上限反而更重要,而不是更不重要。
如果你从这部分只拿走一样东西,就拿走这个清单。它是完整的发布流程,压缩后的精华。
按对话亲和路由,而不是按消息路由。用对话 ID 做哈希。一个客户在单次对话中绝不能遇到两个版本的智能体。
按 5%、10%、25%、50%、100% 递增。每一步都是配置变更,每步至少保持一天——只有当队列平稳时才跳过阶梯。代码变更则重新开始实验。
比较队列,而不是比较绝对值。候选版 vs 基线版,指标包括:错误率、p95、每对话工具调用次数、拒绝率,再加上从实时日志中采样的第八部分指标,在同一时间切片上。
自动回滚。错误率加一个点持续十分钟,或 p95 达到基线 1.5 倍持续十分钟,将流量split 翻零,并在事后通知你。
包装模型以应对硬故障。三次失败打开熔断器 60 秒。备用方案在请求开始时触发,绝不在中途。
通过测量检测静默降级。连续两个夜间运行低于阈值,将流量切换到备用。一个夜晚是噪声。
三层 token 上限。每轮、每对话(我的设 6000)、每日(7 天均值的 3 倍)。账单也是一种指标。
关注缓存读取与总量并重。如果你的提供商报告了缓存读取,它们会告诉你长对话是否真的便宜。
如果我重新做这个系列,会做得不一样的事:从第一部分就在每个调用上捕获 getMetadata().getUsage()。我在这部分才补上了 usage 管道,这意味着第一个成本上限是在一周的部分数据上运行的。Usage 是最便宜的观测数据,它本该从第一天起就是第四部分仪表盘里的一列。
你的发布运行手册是什么样的?你的流量最后一次在没有测试捕捉到的情况下捕获到的变更是什么?我会读每一条回复。
我每周写关于 Java、Spring Boot 和 AI 智能体的文章。订阅,免费。
收藏这一篇。等你的 pairwise 胜者遇到真实流量那天,你会需要这个运行手册的。