Spring AI Agent 漫画实战
最终话 · 第 10 话 PRODUCTION

上线才是开始:可观测、评估、护栏与综合实战 Demo 到生产之间,隔着这四件事:看得见、测得准、拦得住、扛得稳

前九话让你造出一个 Agent;这一话让它在半夜三点不给你打电话。 最后我们把所有零件拼成一个可运行的「运维排障 Agent」。

老 K
功能都跑通了,测试环境效果不错。可以上线了吧?
小 S
测试环境效果好,是抽样偏差。回答下面四个问题再上线:
① 一次调用花了多少 token、哪一步慢?② 你改了提示词,怎么证明没变差?③ 用户诱导它删库怎么办?④ 模型厂商挂了怎么办?

一、可观测:让每一次调用都能被追责

Spring AI 通过 Micrometer 的 Observation API 把模型调用纳入标准观测体系。你要做的是打开它、并接进现有 APM。

# application.yml
management:
  observations:
    enable: true
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
spring:
  ai:
    chat:
      observations:
        include-prompt: false      //!hl 默认 false!打开会把完整提示词写进指标,含用户隐私
        include-completion: false  //!hl
指标 / 信号用途
gen_ai.client.operation(计时器)调用次数、耗时分布、错误率 —— 做 SLO 与告警的基础
gen_ai.client.token.usageprompt / completion token 用量 —— 成本核算与配额的源头
Observation 的 trace/span一次用户请求里包含几次模型调用、几次工具执行 —— 排障必备
SimpleLoggerAdvisor调试期打印请求/响应;生产环境谨慎开启(隐私 + 日志量)
自建埋点:把业务维度带进去

框架指标只有技术维度。你需要的是「哪个租户、哪个场景、哪个用户花了多少钱」。 最干净的做法是写一个 Advisor(第 04 话的能力在这里兑现):

public class BizTraceAdvisor implements BaseAdvisor {

    @Override
    public AdvisedRequest before(AdvisedRequest req, AdvisorContext ctx) {   //!hl
        String tenant = (String) req.advisorParams().get("tenantId");
        ctx.put("startAt", System.currentTimeMillis());
        ctx.put("tenantId", tenant);
        return req;
    }

    @Override
    public AdvisedResponse after(AdvisedResponse res, AdvisorContext ctx) {
        long cost = System.currentTimeMillis() - (long) ctx.get("startAt");
        Usage u = res.response().getMetadata().getUsage();                    //!hl
        meterRegistry.counter("agent.invoke",
                "tenant", String.valueOf(ctx.get("tenantId")),
                "model", res.response().getMetadata().getModel())
                .increment();
        meterRegistry.summary("agent.token",
                "tenant", String.valueOf(ctx.get("tenantId")))
                .record(u == null ? 0 : u.getTotalTokens());                 //!hl
        log.info("[agent] tenant={} cost={}ms tokens={}",
                ctx.get("tenantId"), cost, u == null ? -1 : u.getTotalTokens());
        return res;
    }

    @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 100; } //!hl 外层:能包住所有内部 Advisor
}
隐私红线

include-prompt 开着会把用户输入的全部内容(可能含手机号、身份证、密码)写进指标与链路。默认关闭是对的。 确实需要排查时,用采样 + 脱敏的方式临时开启,或只在预发环境开启。

二、评估:没有评测集的优化都是玄学

Spring AI 提供了 Evaluator 抽象,把"模型输出好不好"变成一个可编程的断言。

// 1) 相关性评估:召回的文档是否真的支撑了回答(RAG 场景)
var relevancy = new RelevancyEvaluator(ChatClient.builder(chatModel).build()); //!hl
boolean pass = relevancy.evaluate(new EvaluationRequest(question, docs, answer)).isPass();

// 2) 事实性评估:回答是否与给定上下文一致(查幻觉)
var factCheck = new FactCheckingEvaluator(ChatClient.builder(chatModel).build()); //!hl

// 3) 自建评估器:按业务规则打分(最实用)
class OpsEvaluator implements Evaluator {
    @Override
    public EvaluationResponse evaluate(EvaluationRequest req) {
        // 规则:结论必须包含根因、影响面、处置建议三项,且引用了至少一个证据来源
        boolean structural = answer.contains("根因") && answer.contains("影响") && answer.contains("建议");
        boolean grounded  = answer.matches("(?s).*(日志|监控|变更记录).*");
        return new EvaluationResponse(structural && grounded, Map.of(
                "structural", structural, "grounded", grounded));
    }
}
黄金问答集 50~200 条(问题+期望) 批量跑评测 CI / 定时任务 Evaluator 打分 规则 + LLM-as-judge 对比基线 低于基线则阻断发布 趋势看板 命中率 / 忠实度 / 成本 失败样本反哺问答集 —— 每一次线上事故都应该变成一条回归用例
三步落地
  1. 从线上真实日志里挑 50~200 条问题,人工标注期望答案(或用强模型生成后人工校正)。
  2. 写一个 @Test 或定时任务批量跑,输出得分与失败明细。
  3. 把"改提示词"变成一个可度量的行为:分数提高才合并,降低就回滚。

三、护栏:把风险挡在模型之外

INPUT

输入侧

  • SafeGuardAdvisor:按敏感词规则拦截
  • PII 脱敏:手机号/身份证/银行卡在进入模型前替换成占位符,返回后再还原
  • 长度与频次限制:防止超长输入与刷量
  • 租户隔离:从会话上下文取 tenantId,不信任模型传来的参数
OUTPUT

输出侧

  • 结构化约束:用 BeanOutputConverter 强制格式
  • 事实性校验:RAG 场景开 allowEmptyContext(false)
  • 敏感内容过滤:涉政/涉暴/竞品/内部机密
  • 引用溯源:要求标注来源,无来源的结论降级展示
// 成本闸门:超预算直接熔断,防止一次异常输入烧掉一天额度
public class BudgetGuardAdvisor implements BaseAdvisor {

    @Override
    public AdvisedRequest before(AdvisedRequest req, AdvisorContext ctx) {
        String tenant = (String) req.advisorParams().get("tenantId");
        if (budgetService.isExhausted(tenant)) {                 //!hl
            throw new BudgetExceededException("tenant=" + tenant); //!hl 抛异常比"让模型回答'没钱了'"更可控
        }
        budgetService.reserve(tenant, estimatedTokens(req));      // 预扣额度 //!hl
        return req;
    }

    @Override
    public AdvisedResponse after(AdvisedResponse res, AdvisorContext ctx) {
        budgetService.settle(tenant(ctx), res.response());   // 按实际用量结算 //!hl
        return res;
    }

    @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 50; } //!hl 比业务 Advisor 更外层
}

四、可靠性:把 LLM 当"会挂的下游服务"

手段怎么配说明
重试spring.ai.retry.*(max-attempts / backoff / on-http-statuses)只重试幂等请求;重试会让写类工具重复执行,必须有幂等键
超时HTTP client connect/read timeout + Resilience4j TimeLimiter流式接口要用"首字节超时 + 总时长超时"双指标
限流Resilience4j RateLimiter / Bucket4j,按租户 + 全局双维度同时要对齐厂商的 RPM/TPM 限额,否则会被上游 429
熔断降级Resilience4j CircuitBreaker + fallbackMethod降级话术 + 人工入口,绝不能把异常栈抛给用户
多模型降级自建 ChatModel 路由 Bean:主模型失败自动切备用不同厂商的 prompt 表现有差异,切换前要回归验证
并发隔离独立线程池 Bulkhead模型变慢时不能拖垮业务线程池

五、成本:四个立竿见影的省钱动作

  1. 模型分级:分类/摘要/路由用廉价模型,最终生成用强模型。通常能省 50%+,体验几乎无损。
  2. 上下文瘦身:记忆窗口(第 05 话)+ RAG token 预算裁剪(第 07 话)+ 子 Agent 上下文隔离(第 09 话)。
  3. 结果缓存:相同问题 + 相同上下文命中缓存(注意缓存 key 要包含 system prompt 版本)。
  4. 减少轮次:一次带工具的对话至少 2 次模型调用。能用 Workflow 一次解决的,别让 Agent 循环三轮。
旁白:成本不是财务问题,是架构问题。每一次"让模型自己决定"都是在账单上签字。 前面九话教的所有确定性手段(路由、裁剪、缓存、闸门),本质上都是在帮你省钱。

六、综合实战:运维排障 Agent(OpsAgent)

需求与约束

告警 / IM 输入 用户问题 + 时间窗 BudgetGuard(预算) BizTrace(埋点) Orchestrator ChatClient 记忆 + RAG + 专家工具 只读工具(自动) 写操作 → 审批队列 结论 + 证据 置信度 / 工单 工具层: LogTools(日志检索,只读) MetricTools(监控指标,只读) DeployTools(发布变更,只读) TicketTools(建工单,写操作需审批) IncidentRagAdvisor(历史故障库检索,allowEmptyContext=false) 治理层: AgentLoop(步数 ≤ 8 / token ≤ 80k / 死循环检测) HumanApproval(写操作:internalToolExecutionEnabled=false + 审批队列) AuditLog(每一步:thought / action / args / observation / token / 耗时,全量落库) 可观测层: Micrometer(次数 / 耗时 / token,按 tenant + 场景打标) 评测集回归 + 线上抽检(每天 20 条人工复核) 告警规则:错误率 > 5%、P95 > 20s、单会话 token > 50k、拒答率突变

代码骨架

// ---------- 1) 领域工具(只读自动执行,写操作需审批) ----------
@Service
public class OpsTools {

    @Tool(description = "检索指定服务在时间窗内的异常日志,返回 TOP 错误类型、出现次数与样例。service 如 order-service,window 形如 2026-10-15 14:00~14:30") //!hl
    public LogDigest searchErrorLogs(
            @ToolParam(description = "服务名") String service,
            @ToolParam(description = "时间窗") String window) {
        return logService.digest(service, window);   // 内部:只读账号 + 行数上限 + 超时 //!hl
    }

    @Tool(description = "查询服务的 P99 延迟、错误率、QPS 在时间窗内的变化趋势")
    public MetricSnapshot queryMetrics(
            @ToolParam(description = "服务名") String service,
            @ToolParam(description = "时间窗") String window) {
        return metricService.snapshot(service, window);
    }

    @Tool(description = "查询时间窗内的发布与配置变更记录,用于判断是否由变更引发")
    public List<ChangeEvent> queryChanges(
            @ToolParam(description = "服务名") String service,
            @ToolParam(description = "时间窗") String window) {
        return deployService.changes(service, window);
    }

    // 写操作:注册为"需审批"工具,由 AgentLoop 拦截
    @Tool(description = "创建故障工单并指派给值班人。这是写操作,必须经过人工确认后才会执行") //!hl
    public String createTicket(
            @ToolParam(description = "故障标题") String title,
            @ToolParam(description = "根因分析") String rootCause,
            @ToolParam(description = "严重程度:P0/P1/P2") String severity) {
        return ticketService.create(title, rootCause, severity);
    }
}
// ---------- 2) 编排:主 Agent 挂载专家工具 + RAG + 记忆 + 护栏 ----------
@Configuration
class OpsAgentConfig {

    @Bean
    ChatClient opsAgent(ChatClient.Builder builder,
                        OpsTools opsTools,
                        VectorStore incidentStore,
                        ChatMemory chatMemory) {

        var writeTools = Set.of("createTicket");   //!hl 写操作白名单(黑名单更危险,务必白名单)

        return builder
            .defaultSystem("""
                你是 SRE 排障总指挥。规则:
                1) 必须先调用至少 2 个取证工具,禁止凭空推断;
                2) 结论必须引用具体证据(日志条数、指标数值、变更单号);
                3) 证据不足时输出"证据不足",并说明还需要什么;
                4) 输出结构:根因 / 影响面 / 处置建议 / 置信度(0-1)。
                """)                                                   //!hl
            .defaultAdvisors(
                new BudgetGuardAdvisor(),                              // order 最外层
                MessageChatMemoryAdvisor.builder(chatMemory).build(),
                RetrievalAugmentationAdvisor.builder()                  //!hl 历史故障库
                    .documentRetriever(VectorStoreDocumentRetriever.builder()
                        .vectorStore(incidentStore)
                        .topK(4).similarityThreshold(0.7).build())
                    .queryAugmenter(ContextualQueryAugmenter.builder()
                        .allowEmptyContext(false).build())
                    .build(),
                new BizTraceAdvisor())
            .defaultToolCallbacks(MethodToolCallbackProvider.builder()
                    .toolObjects(opsTools).build())
            .defaultOptions(ToolCallingChatOptions.builder()
                    // 写操作不在框架内自动执行,交给审批队列 //!hl
                    .internalToolExecutionEnabled(false)                 //!hl
                    .toolExecutionEligibilityPredicate(
                        (opts, resp) -> resp.getResult().getOutput().getToolCalls()
                                .stream().noneMatch(c -> writeTools.contains(c.name()))) //!hl
                    .build())
            .build();
    }
}
// ---------- 3) 循环与闸门 ----------
@Service
public class OpsAgentRunner {

    public AgentResult investigate(String question, String tenantId, String sessionId) {
        int step = 0;
        long tokens = 0;
        Deque<String> actions = new ArrayDeque<>();

        while (true) {
            if (++step > 8)                 return AgentResult.abort("达到最大步数");       //!hl
            if (tokens > 80_000)            return AgentResult.abort("超出 token 预算");    //!hl
            if (looping(actions))           return AgentResult.abort("重复动作,疑似死循环"); //!hl

            ChatResponse r = opsAgent.prompt()
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, tenantId + ":" + sessionId)
                                    .param("tenantId", tenantId))
                    .user(question)
                    .call().chatResponse();

            tokens += usageOf(r);

            var calls = r.getResult().getOutput().getToolCalls();
            if (calls.isEmpty()) return AgentResult.done(textOf(r));       //!hl 无工具调用 = 已给最终结论

            for (var c : calls) {
                actions.add(c.name());
                if (WRITE_TOOLS.contains(c.name())) {
                    approval.submit(new ApprovalTask(sessionId, c));        //!hl 人工审批,不自动执行
                    return AgentResult.waitingApproval(c.name());
                }
                // 只读工具:在沙箱/只读账号下执行,结果回填后进入下一轮
                memory.add(tenantId + ":" + sessionId,
                        List.of(new ToolResponseMessage(Map.of(
                                "result", executor.call(c)))));            //!hl
            }
        }
    }

    private boolean looping(Deque<String> q) {
        return q.size() >= 3 && new HashSet<>(q).size() == 1;
    }
}
// ---------- 4) 接口:流式输出 + 会话隔离 ----------
@RestController
class OpsController {

    @GetMapping(value = "/ops/analyze", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> analyze(@RequestParam String q,
                                @RequestHeader("X-Tenant") String tenant,
                                @RequestParam String sessionId) {
        return opsAgent.prompt()
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, tenant + ":" + sessionId)) //!hl
                .user(q)
                .stream()
                .content();   //!hl 流式:秒级首字,掩盖推理延迟
    }
}

上线检查表(照着打勾)

类别检查项
观测token/耗时/错误率指标已接入监控 □ 按租户打标 □ traceId 贯穿工具调用 □ 告警规则已配 □
评估黄金问答集 ≥ 50 条 □ CI 中跑回归 □ 改提示词需对比基线 □ 线上每日抽检 □
护栏输入脱敏 □ 输出过滤 □ 预算闸门 □ 写操作白名单 + 人工审批 □ 租户隔离 □
可靠超时 □ 重试(仅幂等)□ 限流 □ 熔断降级 □ 备用模型 □ 独立线程池 □
成本模型分级 □ 上下文预算 □ 结果缓存 □ 日报/周报账单 □
安全工具最小权限 □ 工具内二次鉴权(不信任模型参数)□ 注入防护 □ 全量审计日志 □
运维提示词版本化并可回滚 □ Agent 状态可回放 □ 人工接管入口 □ 灰度开关 □

七、进阶路线图

深挖框架
  • 读 ChatClient / DefaultAroundAdvisorChain 源码
  • 读 ToolCallingManager 执行循环
  • 写一个自己的 ChatMemoryRepository
  • 写一个自己的 VectorStore 适配器
补理论
  • 《Building Effective Agents》(Anthropic)
  • ReAct / Reflexion / ToT 论文
  • RAG 综述与 Advanced RAG 技术栈
  • 提示词注入攻防(OWASP LLM Top 10)
做项目
  • 把公司文档变成问答机器人(RAG)
  • 把内部系统工具 MCP 化
  • 给现有业务加一个"自然语言入口"
  • 建一套评测集与看板,量化收益
小 S
十话讲完了。但真正的学习从你把第一个 Agent 塞进生产开始 —— 那时你会遇到教程里没写的问题,而那些问题,才是你的护城河。
老 K
原来最后拼的还是那套老东西:分层、拦截、幂等、可观测、灰度。AI 时代,老兵不死。
大结局:Spring AI 没有发明新的工程真理,它只是把大模型调用重新表述成你熟悉的语言。 一个十年 Java 工程师在 AI 时代真正的优势,不是会调 API,而是知道一个系统怎样才能不崩。
← 上一话:多 Agent 编排 返回目录