做 AI 应用的人,大概都经历过这样的时刻:系统上了线,用户隔三差五反馈"这次怎么又慢又答不到点上",可你打开监控面板,看到满屏的 200 状态码,日志里全是正常的调用记录,却没人能说清楚,某一条回答到底经过了什么、基于什么上下文、花掉了多少成本。
传统 Web 可观测性关心请求量、延迟、错误率,这套东西对 AI 系统仍然有效,但不够。AI 系统多了一些传统后端没有的东西:上下文从哪里来、模型为什么这样回答、工具为什么被调用、答案为什么不可信。要回答这些问题,需要一套围绕"AI 链路"的可观测性:
为什么这次回答慢?
为什么 Token 突然变高?
为什么检索到了错误资料?
为什么 Agent 连续调用同一个工具?
到底是模型错、检索错,还是工具错?
用户看到的答案,基于哪些上下文生成的?
一、先把链路摆出来
一次 AI 请求,从用户发起,到最终答案返回,中间要经过上下文构建、模型调用、Agent 决策、工具执行、质量评估。把这些阶段串起来,就是一条完整的链路:
用户请求
↓
ai.run
↓
context.build
├─ user-context.load
├─ permission-filter
├─ retrieval.search
├─ rerank
└─ context.compile
↓
llm.call
├─ model-routing
├─ request
├─ token-usage
└─ response-parse
↓
agent.step
├─ decision
├─ tool-call
├─ retry
└─ next-step
↓
tool.execute
├─ validation
├─ application-use-case
└─ result
↓
evaluation
├─ citation-check
├─ groundedness
├─ format-check
└─ quality-score
↓
最终答案
在可观测性的语境里,这条链路叫 Trace,每一段叫 Span。
二、先定最小可测性单元
动手做可观测性之前,得先回答一个问题:最小可测性单元是什么?
它指的是,系统里最小的一段可以被独立观察、独立追查、独立回放的东西。对 AI 系统来说,这个单元天然存在——一次完整的请求。从用户发起,到最终答案返回,一次请求自带边界,有起点、有终点、有唯一标识;也自带语义,一次回答的耗时、成本、质量,都发生在这个单元之内。
所以我们把一次请求作为整条链路的锚:一个请求对应一条 Trace,对应一个 runId,所有阶段都作为 Span 挂在这条 Trace 下面。追查任何问题,都从 runId 开始;回放任何行为,都回到这条链路。
先把这一个单元立起来,后面要做的记录、统计、评估,才有挂靠的地方。这也是下面所有内容都围绕这条链路展开的原因。
三、四种手段,各管一段
围绕同一条链路,有四种观测手段:
- Trace 回答"刚才那一次到底发生了什么"——一条请求从进入系统到返回答案的全程。
- Span 回答"这次慢,慢在哪一段"——把一次请求切成一段段,各自记耗时和属性。
- Metric 回答"这一周整体怎么样"——把大量请求聚合起来,看趋势、看分布、看异常。
- Log 回答"某个具体时刻发生了什么事件"——记录像超时、失败、模型切换这样的事件。
这四种手段共用同一条链路:Trace 串起全程,Span 是其中的每一段,Metric 把多次请求统计成趋势,Log 在关键节点留下痕迹。真实排查问题时,往往四样东西要对着看。
四、顺着链路,一个点一个点落实
链路立起来了,接下来逐段落实:每个 Span 记录什么、要注意什么。我们顺着链路往下走。
1. 入口 ai.run:整条链路的锚
ai.run 记录整次请求的元信息,包括 runId、用户、工作空间、任务类型、状态、总耗时、总 token、总成本、Agent 步数、工具调用次数。
它是追查的起点。用户说"那次回答有问题",你拿到 runId,就能把整条链路拉开,看到那次请求调了两次模型、检索了 12 份文档、重试了一次、花了八千多 token。
2. 上下文构建 context.build:模型到底看到了什么
AI 回答的质量,很大程度取决于上下文喂了什么。这个 Span 要记录上下文版本、token 数、文档数、被权限过滤掉的文档数、snapshot_id、是否被裁剪。
这里要注意几个点:
- 一次请求喂了多少份文档?是 12 份还是 0 份?0 份往往意味着模型在"裸答"。
- 上下文是否因为 token 超限被裁剪?裁剪发生在哪一步,裁掉了什么?
- 权限过滤掉多少内容?过滤太多,回答会缺依据;过滤太少,越权内容进了上下文。
- 上下文是否异常膨胀?很多 token 成本上涨,元凶就是上下文无节制地变大。
3. 检索 retrieval.search:凭什么检索到这些
检索决定模型看到的内容,这个 Span 记录检索 query、top_k、实际结果数、向量检索、关键词检索、重排各自的耗时,以及命中结果里的最高分和最低分。
要注意两点。其一,query 尽量记录哈希或脱敏文本,别无条件把原始查询写进日志;其二,"检索结果为空"和"检索结果过多"都要警惕——前者对应无依据回答,后者往往意味着低相关度的文档污染了上下文。
4. 模型调用 llm.call:慢在哪、贵在哪
模型调用值得单独拆成三段子 Span:序列化、HTTP 请求、解析。记录模型名、max_tokens、输入输出 token、finish_reason、实际返回的模型、成本、耗时、重试次数。
分开记录的好处是能分清问题出在谁身上:HTTP 和解析拆开看,才能判断慢在"网络往返"还是"响应解析";输入 token 上涨,才能判断是上下文膨胀还是 prompt 本身变长。再配合长期指标,哪个模型系统性慢、哪个模型贵、哪个模型失败率高,一目了然。
5. 工具调用 tool.call:Agent 到底干了什么
Agent 系统里,模型每调用一次工具,都可能带来实际副作用,所以工具的每一步都要留痕。记录工具名、参数哈希、状态、耗时、重试次数、错误码、幂等键。
普通 Trace 里不要记录完整敏感参数,用参数哈希代替。工具的"连续重复调用"常常是死循环的前兆,把它变成可查询的数据,比等出事故再猜靠谱得多。
6. 评估 evaluation:把"好不好"变成数据
这是 AI 可观测性区别于传统后端的地方。质量评估把"这次回答好不好"变成可以聚合、对比的分数:引用覆盖数、groundedness、相关性、结构校验是否通过、策略校验是否通过、答案长度。
有了这些,质量问题的感知就不再依赖用户投诉。引用覆盖率低、无证据回答比例高、结构化输出失败率高,都能提前在监控上看到苗头。
五、落到代码:一个统一的 Span 封装
链路设计得再漂亮,落地时如果每个业务方法都手动开 Span、关 Span、记异常,代码很快会变成一团浆糊。这里的关键是做一个统一封装,把开 Span、写属性、执行、记异常、关 Span 收敛到一个方法里:
@Component
public class TraceService {
private final Tracer tracer;
public TraceService(Tracer tracer) {
this.tracer = tracer;
}
public <T> T trace(
String spanName,
Consumer<Span> attributeWriter,
Supplier<T> operation
) {
Span span = tracer.spanBuilder(spanName)
.setSpanKind(SpanKind.INTERNAL)
.startSpan();
try (Scope ignored = span.makeCurrent()) {
attributeWriter.accept(span);
T result = operation.get();
span.setStatus(StatusCode.OK);
return result;
} catch (RuntimeException exception) {
span.recordException(exception);
span.setStatus(
StatusCode.ERROR,
safeMessage(exception)
);
throw exception;
} finally {
span.end();
}
}
private String safeMessage(RuntimeException exception) {
String message = exception.getMessage();
return message == null ? exception.getClass().getSimpleName() : message;
}
}
trace() 方法接受三个参数:Span 名、写属性的回调、真正的业务操作。业务代码只需要关心"我这个阶段该记录什么属性",Span 的开、关、异常记录全由框架代劳。
有了这个封装,应用入口的调用就变得很干净——整次请求用一个 ai.run Span 包起来,属性在回调里写:
@Service
public class ApplicationService {
private final TraceService traceService;
private final ContextBuilder contextBuilder;
private final LlmGateway llmGateway;
private final MetricsService metricsService;
public ApplicationService(
TraceService traceService,
ContextBuilder contextBuilder,
LlmGateway llmGateway,
MetricsService metricsService
) {
this.traceService = traceService;
this.contextBuilder = contextBuilder;
this.llmGateway = llmGateway;
this.metricsService = metricsService;
}
public ChatResult execute(ChatCommand command) {
long startedAt = System.nanoTime();
try {
return traceService.trace(
"ai.run",
span -> {
span.setAttribute(
"ai.user.id",
command.userId().toString()
);
span.setAttribute(
"ai.workspace.id",
command.workspaceId().toString()
);
span.setAttribute(
"ai.task.type",
"knowledge_chat"
);
},
() -> executeInsideTrace(command)
);
} finally {
metricsService.recordRequestDuration(
Duration.ofNanos(
System.nanoTime() – startedAt
)
);
}
}
private ChatResult executeInsideTrace(ChatCommand command) {
ContextSnapshot context = buildContext(command);
LlmResponse response = callModel(command, context);
return new ChatResult(
response.text(),
context.citations()
);
}
}
六、各阶段的可观测代码
顺着链路,每个阶段用同样的方式包一层。上下文构建在业务执行完后,把快照的关键信息写回当前 Span:
private ContextSnapshot buildContext(ChatCommand command) {
return traceService.trace(
"ai.context.build",
span -> {
span.setAttribute(
"ai.context.task_type",
"knowledge_chat"
);
span.setAttribute(
"ai.context.version",
"context-v1"
);
},
() -> {
ContextSnapshot snapshot =
contextBuilder.build(
new ContextBuildRequest(
command.userId(),
command.workspaceId(),
command.message(),
12_000
)
);
Span current = Span.current();
current.setAttribute(
"ai.context.document_count",
snapshot.evidence().size()
);
current.setAttribute(
"ai.context.token_count",
snapshot.estimatedTokens()
);
current.setAttribute(
"ai.context.snapshot_id",
snapshot.id().toString()
);
return snapshot;
}
);
}
检索阶段记录 top_k、query 的哈希、实际结果数和最高分,同时把检索耗时记进指标:
@Component
public class ObservableRetriever implements Retriever {
private final KnowledgeSearchPort searchPort;
private final TraceService traceService;
private final MetricsService metricsService;
public ObservableRetriever(
KnowledgeSearchPort searchPort,
TraceService traceService,
MetricsService metricsService
) {
this.searchPort = searchPort;
this.traceService = traceService;
this.metricsService = metricsService;
}
@Override
public List<KnowledgeEvidence> retrieve(
RetrievalRequest request
) {
long startedAt = System.nanoTime();
return traceService.trace(
"ai.retrieval.search",
span -> {
span.setAttribute(
"ai.retrieval.top_k",
request.topK()
);
span.setAttribute(
"ai.retrieval.query_hash",
sha256(request.query())
);
},
() -> {
List<KnowledgeEvidence> results =
searchPort.search(
request.workspaceId(),
request.query(),
request.topK()
);
Span.current().setAttribute(
"ai.retrieval.result_count",
results.size()
);
double maxScore = results.stream()
.mapToDouble(
KnowledgeEvidence::score
)
.max()
.orElse(0.0);
Span.current().setAttribute(
"ai.retrieval.max_score",
maxScore
);
metricsService.recordRetrievalDuration(
Duration.ofNanos(
System.nanoTime()
– startedAt
)
);
return results;
}
);
}
private String sha256(String value) {
// 实际项目中使用稳定的 SHA-256 实现。
return Integer.toHexString(value.hashCode());
}
}
模型调用要记录模型名、token 用量、成本和结束原因,成功和失败分别记入不同的指标。成功时把 token、cost 写进 Span,失败时记录错误类型,让"成功"和"失败"都能被统计:
@Component
public class ObservableLlmGateway implements LlmGateway {
private final LlmGateway delegate;
private final TraceService traceService;
private final MetricsService metricsService;
public ObservableLlmGateway(
LlmGateway delegate,
TraceService traceService,
MetricsService metricsService
) {
this.delegate = delegate;
this.traceService = traceService;
this.metricsService = metricsService;
}
@Override
public LlmResponse generate(LlmRequest request) {
long startedAt = System.nanoTime();
return traceService.trace(
"ai.llm.call",
span -> {
span.setAttribute(
"gen_ai.operation.name",
"chat"
);
span.setAttribute(
"gen_ai.request.model",
request.model()
);
span.setAttribute(
"ai.context.snapshot_id",
request.contextSnapshotId()
.toString()
);
},
() -> {
try {
LlmResponse response =
delegate.generate(request);
Span current = Span.current();
current.setAttribute(
"gen_ai.response.model",
response.model()
);
current.setAttribute(
"gen_ai.usage.input_tokens",
response.usage().inputTokens()
);
current.setAttribute(
"gen_ai.usage.output_tokens",
response.usage().outputTokens()
);
current.setAttribute(
"ai.llm.cost",
response.usage()
.estimatedCost()
.doubleValue()
);
current.setAttribute(
"gen_ai.response.finish_reason",
response.finishReason()
);
metricsService.recordLlmSuccess(
response.model(),
response.usage(),
elapsed(startedAt)
);
return response;
} catch (RuntimeException exception) {
metricsService.recordLlmFailure(
request.model(),
exception.getClass()
.getSimpleName()
);
throw exception;
}
}
);
}
private Duration elapsed(long startedAt) {
return Duration.ofNanos(
System.nanoTime() – startedAt
);
}
}
工具调用同样如此,把成功状态和是否可重试写进 Span,失败时记录错误类型。参数用稳定哈希,不让敏感内容进链路:
@Component
public class ObservableToolGateway {
private final ToolGateway delegate;
private final TraceService traceService;
private final MetricsService metricsService;
public ObservableToolGateway(
ToolGateway delegate,
TraceService traceService,
MetricsService metricsService
) {
this.delegate = delegate;
this.traceService = traceService;
this.metricsService = metricsService;
}
public ToolResult execute(ToolRequest request) {
long startedAt = System.nanoTime();
return traceService.trace(
"ai.tool.call",
span -> {
span.setAttribute(
"ai.tool.name",
request.toolName()
);
span.setAttribute(
"ai.tool.arguments_hash",
stableHash(request.arguments())
);
span.setAttribute(
"ai.run.id",
request.runId().toString()
);
},
() -> {
try {
ToolResult result =
delegate.execute(request);
Span.current().setAttribute(
"ai.tool.success",
result.success()
);
Span.current().setAttribute(
"ai.tool.retryable",
result.retryable()
);
metricsService.recordToolCall(
request.toolName(),
result.success(),
elapsed(startedAt)
);
return result;
} catch (RuntimeException exception) {
metricsService.recordToolFailure(
request.toolName(),
exception.getClass()
.getSimpleName()
);
throw exception;
}
}
);
}
private String stableHash(
Map<String, Object> arguments
) {
return Integer.toHexString(arguments.hashCode());
}
private Duration elapsed(long startedAt) {
return Duration.ofNanos(
System.nanoTime() – startedAt
);
}
}
七、Metric:把事件变成趋势
Trace 回答单次请求,Metric 回答长期趋势。两者要配合使用:Trace 里的 token、耗时、成本,通过 Metric 聚合成一周的曲线、按模型切分的对比、失败率的变化。
用 Micrometer 做这套,核心是两个概念:Timer 记录耗时分布,Counter 记录累计计数。加 tag 让指标可以被切片——按模型、按工具、按状态:
@Component
public class MetricsService {
private final MeterRegistry registry;
public MetricsService(MeterRegistry registry) {
this.registry = registry;
}
public void recordRequestDuration(Duration duration) {
Timer.builder("ai.request.duration")
.description("AI request duration")
.register(registry)
.record(duration);
}
public void recordRetrievalDuration(Duration duration) {
Timer.builder("ai.retrieval.duration")
.register(registry)
.record(duration);
}
public void recordLlmSuccess(
String model,
LlmUsage usage,
Duration duration
) {
Timer.builder("ai.llm.duration")
.tag("model", model)
.tag("status", "success")
.register(registry)
.record(duration);
Counter.builder("ai.llm.input.tokens")
.tag("model", model)
.register(registry)
.increment(usage.inputTokens());
Counter.builder("ai.llm.output.tokens")
.tag("model", model)
.register(registry)
.increment(usage.outputTokens());
Counter.builder("ai.llm.cost")
.tag("model", model)
.register(registry)
.increment(
usage.estimatedCost()
.doubleValue()
);
}
public void recordLlmFailure(
String model,
String errorType
) {
Counter.builder("ai.llm.failure")
.tag("model", model)
.tag("error", errorType)
.register(registry)
.increment();
}
public void recordToolCall(
String tool,
boolean success,
Duration duration
) {
Timer.builder("ai.tool.duration")
.tag("tool", tool)
.tag("status", success ? "success" : "failure")
.register(registry)
.record(duration);
}
public void recordToolFailure(
String tool,
String errorType
) {
Counter.builder("ai.tool.failure")
.tag("tool", tool)
.tag("error", errorType)
.register(registry)
.increment();
}
}
八、链路之外,还要一张业务账本
OpenTelemetry 适合看链路,但 AI 系统还需要业务侧可以查询的运行记录:一次请求谁发的、用了哪个模型、花了多少钱、检索了什么、调了哪些工具。这些记录要能按时间、按用户、按模型检索,所以落库更合适。
四张表大致这样设计。
ai_run 记录一次完整请求的汇总——traceId、用户、工作空间、任务类型、状态、模型、上下文快照、token 用量、成本、Agent 步数、工具调用次数、耗时、失败码:
create table ai_run (
id uuid primary key,
trace_id varchar(64) not null,
user_id uuid not null,
workspace_id uuid not null,
task_type varchar(50) not null,
status varchar(30) not null,
model varchar(100),
prompt_version varchar(50),
context_snapshot_id uuid,
input_tokens bigint not null default 0,
output_tokens bigint not null default 0,
estimated_cost numeric(12, 6) not null default 0,
agent_step_count integer not null default 0,
tool_call_count integer not null default 0,
duration_ms bigint,
failure_code varchar(100),
created_at timestamp not null,
completed_at timestamp
);
ai_model_call 记录每一次模型调用的明细,按 ai_run 关联:
create table ai_model_call (
id uuid primary key,
ai_run_id uuid not null,
span_id varchar(32),
provider varchar(50) not null,
model varchar(100) not null,
prompt_version varchar(50),
context_snapshot_id uuid,
input_tokens bigint not null,
output_tokens bigint not null,
estimated_cost numeric(12, 6) not null,
duration_ms bigint not null,
retry_count integer not null default 0,
finish_reason varchar(50),
status varchar(30) not null,
error_code varchar(100),
created_at timestamp not null
);
ai_retrieval 记录每次检索的 query 哈希、类型、请求与实际结果数、分数、耗时:
create table ai_retrieval (
id uuid primary key,
ai_run_id uuid not null,
query_hash varchar(128) not null,
retrieval_type varchar(30) not null,
requested_top_k integer not null,
result_count integer not null,
max_score numeric(8, 6),
min_score numeric(8, 6),
duration_ms bigint not null,
created_at timestamp not null
);
ai_tool_call 记录每次工具调用:
create table ai_tool_call (
id uuid primary key,
ai_run_id uuid not null,
tool_name varchar(100) not null,
arguments_hash varchar(128),
status varchar(30) not null,
retry_count integer not null default 0,
duration_ms bigint,
error_code varchar(100),
created_at timestamp not null,
completed_at timestamp
);
注意,这里所有的"内容"都用了哈希和数量,原始 query、原始参数、完整上下文都不进这张账本——它们属于另一套需要加密和权限的存储。
九、一张 Dashboard 该有什么
监控页面至少要有几块固定的板子。
请求健康度:请求总量、成功率、P50/P95/P99 延迟、失败类型分布。
模型:各模型调用次数、平均延迟、Token 使用量、平均请求成本、超时率、限流率。
RAG:平均检索文档数、平均最高相关度、空检索率、重排耗时、Context Token 数、裁剪率。
Agent:平均步骤数、最大步骤数、工具调用数量、循环中止次数、工具失败率。
质量:引用覆盖率、无证据回答比例、结构化输出失败率、人工点赞率、人工纠错率。
十、一次问题定位的完整演示
把前面的东西串起来,模拟一次真实排查。
用户反馈"这次回答又慢又不准确"。拿到 runId 后,打开这条链路:
ai.run 9.1s
├── context.build 1.3s
│ ├── retrieval.search 0.4s
│ ├── rerank 0.7s
│ └── compile 0.2s
│
├── llm.call 6.8s
│ input_tokens: 18,200
│ output_tokens: 1,100
│
└── evaluation 0.3s
groundedness: 0.42
citation_count: 1
一次链路同时给出四个结论:
- 慢。总耗时 9.1 秒,其中模型调用占了 6.8 秒,主要瓶颈在 LLM 本身。
- Token 高。输入 token 达到 18,200,大概率是上下文过大——再回头看 context.build,果然喂进去了不少文档。
- 不准确。groundedness 只有 0.42,说明回答与依据内容的贴合度很差。
- 引用少。整份回答只引用了 1 份文档,结合检索结果数,能进一步判断是检索太窄还是重排丢了相关内容。
每条线索都指向链路里具体的一环。能这样定位问题,才谈得上可观测。
十一、可观测也有边界:隐私与日志
可观测性最容易犯的错,是"为了排查问题,把所有 Prompt 和用户资料全文打日志"。这个做法要避免。
普通 Trace 里只记录这些:
runId
snapshotId
promptHash
queryHash
documentCount
tokenCount
model
cost
duration
status
errorCode
完整上下文单独存放,遵循加密存储、严格权限、有限保留期、单独审计四原则。私密资料直接进普通日志平台,会带来合规风险,慎之又慎。
十二、落地顺序:先闭环,再丰富
可观测性方案再完整,一次性全部做完的性价比也有限。更务实的做法是分阶段推进。
第一阶段,先跑通最小闭环。 Trace ID、Run ID、ai.run、context.build、retrieval.search、llm.call 这些核心 Span,加上模型 Token、成本、耗时,工具调用耗时与状态,统一错误码。这一阶段的目标很简单:任何一次请求,都能凭 runId 把链路拉出来。
第二阶段,丰富记录与告警。 Context Snapshot、Prompt Version、模型调用记录、RAG 检索记录、Dashboard、失败率与延迟告警。到这个阶段,趋势可见,异常可告警。
第三阶段,做评估与回归。 Replay、Groundedness 评估、引用质量评估、历史版本对比、模型 A/B、Prompt 回归测试、成本异常检测。质量被量化,改动有回归,成本有预警。
最后:链路要变成决策依据
一套可观测性建起来,最终的目的只有一个:当系统表现不好时,你能知道该动 Prompt、检索、模型、上下文还是工具。
一次 AI 请求的完整链路,连同上下文来源、模型消耗、工具行为和质量结果一起被记录下来,问题就从"猜"变成了"查"。这是可观测性给 AI 系统带来的最大改变。

