Administrator
发布于 2026-03-15 / 969 阅读
13

Agent 上生产半年:我们踩过的 10 个坑

半年 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 循环没停——因为我们当时把「工具循环」跑在一个独立的异步任务里,客户端断开不会取消它。

根因有三个,缺一不可:

  1. 工具调用没有全局步数上限;
  2. 客户端断开没有传播取消信号;
  3. 没有单会话的成本熔断。

修复:

@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,20026,000
任务完成率71%88%
人工转接率29%12%
P99 延迟7.8s3.2s
单次对话成本¥0.31¥0.10
P2 及以上故障2 次0 次(近 4 个月)
死循环事故3 次0 次

小结

如果要我把这 10 个坑归成一句话:Agent 的难点不在「让它能跑」,在「让它失控的时候能被拦住」。

我们这半年的工程投入,大概 60% 花在了各种护栏上——步数限制、成本熔断、权限校验、歧义拦截、评测回归。真正花在「让 Agent 更聪明」上的时间反而很少。这个比例一开始让我挺意外的,但现在觉得很合理。

传统软件的行为是确定的,错误的输入最多得到一个错误的结果。Agent 的行为是不确定的,错误的输入可能导致它去调一个不该调的接口、花掉一千块钱、或者转三小时的圈。它的失败模式比传统软件多了一个维度,所以防御的维度也要多一层。

给准备上生产的团队一个建议:在你写第一个工具之前,先把步数上限、成本上限、权限模型这三件事定下来。这三样东西事后补的代价,是事前设计的五倍以上。

参考