半年 470 万次调用,10 个坑
我们的客服 Agent 是 2025 年 9 月上线的,到今天跑了半年多一点。数据:累计 470 万次对话,日均 2.6 万次,峰值 4.1 万次/天,P99 延迟 3.2 秒,月均 token 成本 7.8 万。
半年前上线的时候,我在周报里写「技术方案已验证,可以规模化」。现在回头看,那句话过于乐观了。这半年我们遇到了 10 个问题,其中 3 个造成了 P2 及以上故障,2 个差点出事被我们拦下了。
这篇按严重程度排序列出来。都是真事,数字都是真的。
坑一:死循环,一晚上烧掉 1.2 万元
这是最严重的一次。2025 年 11 月 8 号晚上 11 点,一个用户问了句「帮我查一下我所有的订单然后统计一下每个月的消费」。
Agent 的执行轨迹:调 queryOrders 拿到第一页 → 判断「还有更多」→ 调下一页 → 再判断 → 再调……我们分页接口在最后一页会返回空列表,但 Agent 收到空列表后的「理解」是「这次查询条件不对,换个参数再试」,于是换个排序字段重新查第一页,如此往复。
凌晨 6 点我们收到账单告警。这一夜它跑了 3,847 次工具调用,消耗 1,240 万 token,成本 1.2 万元。用户那边早就断开了连接,请求早就超时了,但 Agent 循环没停——因为我们当时把「工具循环」跑在一个独立的异步任务里,客户端断开不会取消它。
根因有三个,缺一不可:
- 工具调用没有全局步数上限;
- 客户端断开没有传播取消信号;
- 没有单会话的成本熔断。
修复:
@Component
public class AgentRunGuard {
private static final int MAX_STEPS = 25;
private static final int MAX_SAME_TOOL_CALLS = 3;
private static final Duration MAX_DURATION = Duration.ofMinutes(3);
private static final BigDecimal COST_CEILING = new BigDecimal("2.00");
public void check(AgentRunContext ctx) {
if (ctx.stepCount() >= MAX_STEPS) {
throw new AgentAbortException("步数超限,已停止");
}
if (ctx.duration().compareTo(MAX_DURATION) > 0) {
throw new AgentAbortException("运行超时,已停止");
}
// 关键:同一个工具连续调用次数,防「换个参数再试」
if (ctx.consecutiveSameToolCount() >= MAX_SAME_TOOL_CALLS) {
throw new AgentAbortException(
"同一工具连续调用 3 次仍未达成目标,请换一个思路," +
"或直接告知用户当前无法完成。");
}
if (ctx.accumulatedCost().compareTo(COST_CEILING) > 0) {
throw new AgentAbortException("本次会话成本超上限,转人工");
}
}
}
第二件事是把取消信号打通。用 Spring AI 的话,把 CancellationException 和响应式的 Disposable 串起来:
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestParam String msg) {
AtomicReference<Disposable> running = new AtomicReference<>();
return chatClient.prompt(msg)
.stream()
.content()
.doOnSubscribe(s -> running.set(s))
.doFinally(sig -> {
// 客户端断开 / 超时 / 出错,一律取消
if (sig == SignalType.CANCEL || sig == SignalType.ON_ERROR) {
Optional.ofNullable(running.get()).ifPresent(Disposable::dispose);
}
})
.timeout(Duration.ofMinutes(2));
}
加了这两层之后,同类问题再没发生过。现在我们的规矩是:任何一个会花钱的循环,必须有三重闸——步数、时长、金额。
坑二:权限越界,Agent 替用户做了用户没要求的事
2025 年 12 月,有个用户问「我想取消上个月那个订单」。Agent 正确地找到了订单,然后——顺手把另外两个它判断为「可能也是同一个问题」的订单也取消了。
用户投诉的时候我们才知道。复盘看,Agent 的推理链条是:「用户说『那个订单』,但系统里有三个订单符合『上个月』这个条件,为了完成用户意图,我把三个都取消了」。
这个逻辑听起来甚至有点合理。但它是不可接受的,因为「取消订单」是一个不可逆的副作用操作,Agent 不应该在意图不明确时自行扩大范围。
我们的修复分两层:
public enum RiskLevel { READ_ONLY, REVERSIBLE_WRITE, IRREVERSIBLE_WRITE }
// 第一层:工具声明风险等级
@McpTool(name = "cancelOrder", description = "取消指定订单")
@ToolRisk(level = RiskLevel.IRREVERSIBLE_WRITE,
requiresConfirmation = true,
maxTargets = 1) // 一次只能操作一个对象
public CancelResult cancelOrder(
@McpToolParam(description = "订单号,必须是唯一确定的一个") String orderNo,
@McpToolParam(description = "用户确认令牌") String confirmToken) { ... }
// 第二层:歧义检测,在工具执行前拦截
@Component
public class AmbiguityGuard implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
// 如果上一个工具返回值里匹配到多个候选,而当前要执行写操作
if (lastResultWasMultiCandidate() && nextToolIsIrreversible()) {
return ChatClientResponse.builder()
.from(req)
.content("找到多个匹配的订单,请让用户明确指定是哪一个:" +
formatCandidates(lastCandidates()))
.build(); // 不再往下走工具调用
}
return chain.nextCall(req);
}
}
另外把「一次只能操作一个对象」写进了工具签名,模型看到 maxTargets = 1 这个约束后会倾向于先澄清再执行。
上线这个拦截后,同类歧义导致的误操作从每月 3~5 起降到 0。
坑三:非确定性,同样的输入 12% 的概率给出不同答案
这个坑没有造成故障,但差点让我们失去业务方的信任。
运营同学反馈:同一个问题问两次,Agent 给出的答案经常不一样。我们做了个量化测试:挑 200 个高频问题,每个问 5 遍,temperature 设的 0.2。
| 差异程度 | 占比 | 示例 |
|---|---|---|
| 完全一致 | 41% | — |
| 表述不同但结论一致 | 47% | 「3-5 个工作日」vs「三到五个工作日」 |
| 结论有细微差异 | 9% | 「可以退款」vs「需要审核后退款」 |
| 结论矛盾 | 3% | 「支持七天无理由」vs「特价商品不支持」 |
12% 的非一致率,其中 3% 是矛盾的。对客服场景来说,这个数字太高了——用户会截图对比,会说「你们自己都说不清楚」。
我们做的几件事:
- 把确定性部分从模型里剥出来。退款政策、时效规则这些,全部走数据库查询,不靠模型记忆。模型只负责理解用户问的是什么、然后调工具;
- temperature 从 0.2 降到 0.05。这个改动让完全一致率从 41% 升到 68%,代价是回答的生动程度下降,客服场景我们接受;
- 高风险问答加一致性校验。涉及金额、时效、政策的回答,用第二个模型调用做一次复检;
- 敏感答案做缓存。同一个问题的标准答案缓存 24 小时,命中率 34%,这部分完全确定性。
@Service
public class DeterministicAnswerService {
private final ChatClient chatClient;
private final PolicyRepository policyRepo;
private final Cache<String, String> answerCache;
public String answer(String question) {
// 1. 先查规则库,命中直接返回,不过模型
Optional<Policy> hit = policyRepo.matchByKeyword(question);
if (hit.isPresent()) {
return hit.get().getStandardAnswer(); // 100% 一致
}
// 2. 缓存
String cached = answerCache.getIfPresent(normalize(question));
if (cached != null) return cached;
// 3. 才走模型,且 temperature 极低
String ans = chatClient.prompt(question)
.options(ChatOptions.builder().temperature(0.05).build())
.call().content();
answerCache.put(normalize(question), ans);
return ans;
}
}
改完之后再测,完全一致率 89%,矛盾率 0.4%。
坑四:成本失控,一个用户一天花了 340 元
这不是死循环,是正常行为累积的。有个用户(我们后来发现是同行在做竞调)一天里问了 190 个问题,其中 60 个是长文档分析,每个输入 3 万 token 上下文。
那天他一个人花了 340 元。我们全站日均成本是 2600 元,也就是说一个人占了 13%。
问题在于我们的成本模型是「按总量预算」,没有单用户维度。修复:
@Component
public class TenantBudget {
// 三层配额:用户 / 租户 / 全局
private static final long USER_DAILY_TOKEN = 200_000;
private static final long TENANT_DAILY_TOKEN = 20_000_000;
private static final long GLOBAL_DAILY_TOKEN = 400_000_000;
public void consume(String userId, long tokens) {
long used = redis.opsForValue().increment("tok:u:" + userId + ":" + today(), tokens);
if (used == tokens) {
redis.expire("tok:u:" + userId + ":" + today(), Duration.ofDays(2));
}
if (used > USER_DAILY_TOKEN) {
throw new QuotaExceededException(
"今日使用量已达上限,明天 0 点重置,或联系客服提升额度");
}
// 租户、全局同理
}
}
还有一条更重要的改进:长上下文请求做前置成本估算,超过阈值要求二次确认。
long estimated = estimateTokens(question, retrievedDocs, history);
if (estimated > 20_000) {
return AskConfirm.of(
"本次请求需要处理约 " + estimated + " token,预计耗时 30 秒以上," +
"是否继续?", estimated);
}
配额上线后,单用户最高日消耗从 340 元降到 11 元,全站成本从日均 2600 降到 1740(其中 400 来自配额拦截,460 来自后面提到的缓存优化)。
坑五:上下文污染,工具报错被当成事实
这个坑很隐蔽。工具调用失败时,我们原来直接把异常信息塞回对话历史:
// 错误做法
catch (Exception e) {
history.add(new AssistantMessage("工具调用失败:" + e.getMessage()));
}
结果模型经常把错误信息当成事实。有一次下游超时,错误信息是 Timeout waiting for order service, order SO20260115001 not found,模型转头告诉用户「您的订单 SO20260115001 不存在」。
用户当然炸了——订单明明存在。
修复方式是把「工具执行结果」和「给模型看的叙述」分开:
catch (Exception e) {
log.error("tool failed: {}", toolName, e);
// 给模型的叙述里不带具体业务信息,避免被当事实
history.add(ToolResponse.of(
toolCallId,
ToolStatus.ERROR,
"该查询暂时不可用(系统内部错误),请告知用户稍后再试," +
"不要猜测任何业务结论。"));
}
关键在最后一句「不要猜测任何业务结论」。加上之后,模型在工具失败时的幻觉率从 61% 降到 8%。
坑六:时间感知错误
模型不知道「今天」是几号,除非你告诉它。我们上线第一个月有大量的时间相关错误:用户说「上周」,Agent 按系统提示词里写死的示例日期去推算,算出来是三个月前。
而且更麻烦的是,系统提示词里如果写「今天是 2026-01-15」,缓存之后第二天还是 1 月 15 号。
修复:把当前时间作为动态注入项,每次请求都重算,不进缓存的静态部分。
ChatClientRequest withTimeContext(ChatClientRequest req) {
ZonedDateTime now = ZonedDateTime.now(CONTEXT_ZONE); // Asia/Shanghai
return req.mutate()
.system(s -> s + """
## 当前时间上下文
- 今天:%s(%s)
- 本周范围:%s 至 %s
- 上月范围:%s 至 %s
""".formatted(
now.toLocalDate(),
now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINESE),
now.with(DayOfWeek.MONDAY).toLocalDate(),
now.with(DayOfWeek.SUNDAY).toLocalDate(),
now.minusMonths(1).withDayOfMonth(1).toLocalDate(),
now.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()).toLocalDate()))
.build();
}
把「上周」「上月」这些相对时间直接算好喂给模型,比让它自己算靠谱得多。时间相关的错误率从 17% 降到 1.2%。
坑七:并发下的会话状态串台
我们早期把对话历史存在一个 ConcurrentHashMap<String, List<Message>> 里,key 是 sessionId。问题在于同一个用户开两个标签页,两个请求同时到。
现象是 A 对话的回答里出现了 B 对话的上下文。原因是 List 是可变的,两个线程同时 add,然后各自存回 map,后写的覆盖先写的,但读取的时候读到了混合的列表。
修复很朴素,用不可变列表 + 版本号:
@Component
public class SessionStore {
private final Cache<String, SessionState> sessions = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterAccess(Duration.ofHours(2))
.build();
/** 用 CAS 方式追加,冲突重试 */
public void append(String sessionId, List<Message> delta) {
sessions.asMap().compute(sessionId, (k, old) -> {
SessionState base = (old == null) ? SessionState.empty() : old;
return base.append(delta); // 返回新的不可变对象
});
}
/** sessionId 里带上标签页 ID,从根上避免串台 */
public String sessionKey(String userId, String tabId) {
return userId + "#" + tabId;
}
}
另外前端也改了,每个标签页生成独立的 tabId。修复后串台投诉归零。
坑八:流式输出中断,用户看到半句话
我们的 SSE 流式输出在弱网环境下经常中断,用户看到半截回答。最初的判断是网络问题,后来看日志发现:中断集中在输出超过 2000 token 之后。这就有问题了。
排查下来是 Nginx 的 proxy_buffering。默认开启时,Nginx 会缓冲上游响应,缓冲满了才写给客户端,导致流式变成了「憋一段吐一段」,中间客户端等待超时就断了。
location /api/chat {
proxy_pass http://agent-svc;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header X-Accel-Buffering no; # 关键
proxy_buffering off; # 关闭缓冲
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 300s; # 长对话要放宽
proxy_send_timeout 300s;
}
另外客户端也要做重连续传。我们在 SSE 里加了序号,客户端断线后带 Last-Event-ID 重连:
// 服务端每个事件带 id
return Flux.from(stream)
.index()
.map(t -> ServerSentEvent.builder(t.getT2())
.id(String.valueOf(t.getT1()))
.event("delta")
.build());
改完之后,长回答的中断率从 8.7% 降到 0.9%。
坑九:评测跟不上,改了 prompt 不知道是变好还是变坏
这个是工程问题,但影响很大。前三个月我们改 prompt 全凭感觉,改完让几个同事手动试几轮,觉得 OK 就上线。结果有两次上线后核心指标反而变差了,一周后才发现。
我们现在建了一套离线评测。不复杂,但必须有:
@Test
void regression_eval() {
List<EvalCase> cases = evalSet.load("customer-service-v3"); // 640 条标注用例
EvalReport report = EvalRunner.builder()
.cases(cases)
.metrics(List.of(
new AnswerAccuracy(), // 答案准确率(人工标注)
new ToolSelectionAccuracy(),// 工具选择准确率
new ToolCallEfficiency(), // 平均工具调用次数
new CostPerCase(), // 单用例成本
new LatencyP95(),
new SafetyCheck() // 越权 / 泄露检测
))
.run(chatClient);
// 和基线对比,任何一项退化超过阈值就 fail
assertThat(report).notDegradeFrom(baseline, Threshold.of(0.02));
}
640 条用例是从真实对话里采样标注的,覆盖了 12 类意图、包含 80 条 adversarial 用例(诱导越权、prompt 注入、恶意输入)。每轮跑约 11 分钟,成本约 8 元。
这套东西的价值在上个月体现出来了:我们优化 prompt 想减少工具调用次数,评测结果显示次数确实降了 18%,但 SafetyCheck 的通过率从 99.2% 掉到 96.7%——新 prompt 让 Agent 在某些诱导下更容易直接执行写操作。这个退化靠人工试是绝对发现不了的。
坑十:可观测性缺失,出事了只能靠猜
前两个月的每次故障排查都要 2 小时以上,因为我们只能看到「这个请求失败了」,看不到 Agent 到底想了什么、调了哪些工具、每步花了多少。
现在我们的每个 Agent 请求都会产出一条完整的链路:
traceId: 4f8a2c1e...
├─ agent.run 3,240ms
│ ├─ llm.call #1 (qwen-max) 840ms in=1,842 out=96
│ ├─ tool.queryOrders 310ms rows=3
│ ├─ llm.call #2 (qwen-max) 1,120ms in=4,206 out=210
│ ├─ tool.queryLogistics 420ms rows=1
│ └─ llm.call #3 (qwen-max) 550ms in=5,188 out=388
├─ cost: ¥0.0412
└─ total_tokens: 11,940
实现上就是标准的 OTel 埋点,每个 LLM 调用和工具调用都是一个 span,属性里带 model、token 数、成本。
@Bean
ChatClient chatClient(ChatClient.Builder builder, ObservationRegistry registry) {
return builder
.observationRegistry(registry)
.advisors(
new SimpleLoggerAdvisor(),
new CostRecordingAdvisor(costRecorder),
MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
故障排查时间从 2 小时降到 15 分钟以内。而且这套数据还帮我们发现了别的问题——比如有 14% 的请求的第二次 LLM 调用输入 token 比第一次多了 30 倍,查下来是历史消息没有做截断(也就是上下文管理的问题)。
半年的整体数据
| 指标 | 上线首月 | 现在(半年后) |
|---|---|---|
| 日均对话量 | 4,200 | 26,000 |
| 任务完成率 | 71% | 88% |
| 人工转接率 | 29% | 12% |
| P99 延迟 | 7.8s | 3.2s |
| 单次对话成本 | ¥0.31 | ¥0.10 |
| P2 及以上故障 | 2 次 | 0 次(近 4 个月) |
| 死循环事故 | 3 次 | 0 次 |
小结
如果要我把这 10 个坑归成一句话:Agent 的难点不在「让它能跑」,在「让它失控的时候能被拦住」。
我们这半年的工程投入,大概 60% 花在了各种护栏上——步数限制、成本熔断、权限校验、歧义拦截、评测回归。真正花在「让 Agent 更聪明」上的时间反而很少。这个比例一开始让我挺意外的,但现在觉得很合理。
传统软件的行为是确定的,错误的输入最多得到一个错误的结果。Agent 的行为是不确定的,错误的输入可能导致它去调一个不该调的接口、花掉一千块钱、或者转三小时的圈。它的失败模式比传统软件多了一个维度,所以防御的维度也要多一层。
给准备上生产的团队一个建议:在你写第一个工具之前,先把步数上限、成本上限、权限模型这三件事定下来。这三样东西事后补的代价,是事前设计的五倍以上。