前九话让你造出一个 Agent;这一话让它在半夜三点不给你打电话。 最后我们把所有零件拼成一个可运行的「运维排障 Agent」。
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.usage | prompt / 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));
}
}
@Test 或定时任务批量跑,输出得分与失败明细。SafeGuardAdvisor:按敏感词规则拦截BeanOutputConverter 强制格式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 更外层
}
| 手段 | 怎么配 | 说明 |
|---|---|---|
| 重试 | 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) 领域工具(只读自动执行,写操作需审批) ----------
@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 执行循环ChatMemoryRepositoryVectorStore 适配器全 10 话完结。建议再回头从第 04 话(Advisor)与第 06 话(工具)精读一遍 —— 那是整个 Spring AI 的两根承重墙。