降SpringAI阿里第17掌-羝羊触藩-观星治理
降SpringAI阿里第17掌-羝羊触藩-观星治理
模型、工具、节点、远程 Agent,一条失败请求到底死在哪一层?
[读者] 已经把数字人 Agent 跑起来的 Java 工程师
[痛点] 线上只留一句「抱歉,我暂时无法回答」
[现在读] 第16掌刚把服务立起来,上线前必须先能看见
[读完] 能独立搭出身份贯穿全链路的诊断面与 Admin 运营面
[旧方案] 关键位置 log.info + 断点 + 手动复现
|
v
[新需求] 一次请求跨越模型、Tool、RAG、Graph Node 与远程 Agent
|
v
[冲突] 各层日志没有共同身份,无法判定属于同一次请求
|
v
[后果] 只能看到最外层抛出的异常,定位靠猜,上线靠运气
我是老李。前几年做后端和运维,最怕的不是事故本身,是事故发生后群里那句「你再复现一下」。能复现的问题是好问题,不能复现的问题才是生产问题。这两年我把精力压在 Spring AI Alibaba 上,从第1掌的选型到第16掌把数字人立成一个可启动的 Spring Boot 服务,一路走来最大的感受是:Agent 系统的复杂度不在「写出来」,在「出问题之后你还认不认得它」。
数字人项目现在的家底已经很具体了:注册登录、数字人项目 CRUD、标题主色背景图、开场白结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互。框架侧也已经走到 ReactAgent、Tool Calling、MCP、RAG、Graph Runtime 这一层。功能是齐的,但一旦线上有人问「为什么这次没回答」,我们的回答能力还停留在 grep 日志。
这次要动手的不是新功能,是一个在事故里才被需要、但必须提前建好的东西:可观测、诊断与运营。参考的标杆很明确——DataAgent 展示了 StateGraph + Sandbox + MCP + RAG 的企业型组合,DeepResearch 展示了多 Agent + 动态规划 + Hybrid RAG + Sandbox 加 Langfuse 可观测,Lynxe(原 JManus)用来理解高确定性通用 Agent 的工程结构。而落到我们自己的系统里,观测面要接的是官方主仓库 v1.1.2.2 分支下的 spring-ai-alibaba-admin 模块。
一次失败的请求,你凭什么证明它死在模型、Tool、RAG、Graph Node 还是远程 Agent 上?
你是继续往业务代码里加第一百行日志,还是把身份打通到每一层调用?
如果 traceId 打通了,但 sessionId 和 threadId 对不上号,你是不是依然定位不到那条具体的会话?
01、故事
上周五下午四点,客户成功团队在 Web 运行页上开了一个新数字人「销售助理」。前两轮问答正常,第三轮用户问「把这个月的订单按区域拆一下」,页面转了将近二十秒,最后吐出一句「抱歉,我暂时无法回答」。截图当场发到群里,问题很直接:是模型挂了,还是你们代码有问题?
我登录服务器翻日志,看到的是这样几行:
2025-xx-xx 16:03:11.482 INFO ChatController : receive message, project=p-1001
2025-xx-xx 16:03:12.310 INFO RagRetriever : retrieve docs, size=3
2025-xx-xx 16:03:29.905 ERROR GlobalExceptionHandler : chat failed
三行日志,三个不同的线程名,没有一行和另外两行有任何共同标识。我既不知道第二行属于哪一次请求,也无法确认第三行就是上面那两次的结果。中间那十几秒里,ReactAgent 到底循环了几轮、调用了哪个 Tool、有没有把子任务甩给远程 Agent,日志里一个字都没有。
任务是:把这一次失败请求,精确定位到模型、Tool、RAG、Graph Node 或远程 Agent 这五层中的某一层。
当时的方案是最原始的:关键位置 log.info,出问题靠肉眼对时间戳。这个方案在单体、单线程、单模型的时代够用——一个请求进来,从头到尾一条线程,日志天然按顺序排。可数字人项目走到今天,一次对话要穿过 ChatClient、Memory、Tool Calling、RAG 检索、ReactAgent 的多轮循环,还可能通过 A2A 把子任务交给远程 Agent。线程切了,进程换了,时间戳成了唯一的线索,而时间戳在并发下什么都不是。
那天晚上我做了两件事:第一件,让一条请求的身份贯穿从 HTTP 到模型、Tool、RAG、Graph Node 和远程 Agent 的每一层;第二件,把 Admin 跑起来,让这条链路被人看得见。这一掌讲的就是把「猜」变成「证」的过程。
一屏日志乱
三处各为凭
无号难相认
有痕方可寻
02、问题
旧方案失效得非常彻底。日志是有的,但它在异步和跨进程面前失去了「归属」:Servlet 线程、Tool 执行线程池线程、Graph 异步节点线程、远程 Agent 的网络调用,各自写各自的日志,中间没有一根线把它们串起来。于是排查的方式退化成「按时间戳对齐」,而时间戳粒度是毫秒,线上并发是几十上百,对齐的成功率基本靠运气。
业务影响是直接的。客户问「为什么没回答」,我们只能回答「正在排查」。这三个字说出来第三次的时候,客户就不信你了。更麻烦的是,我们无法回答另外两个更关键的问题:这次失败是可重试的(比如模型超时)还是不可重试的(比如输入非法)?同类失败在过去一周发生了多少次、集中在哪个项目?
技术上,问题可以拆成四个可验证的表现:
第一,日志里没有统一的 traceId,无法把一次请求的所有输出判定为同一次请求。
第二,即使有 traceId,它在 Tool 执行线程、异步节点、远程调用处会丢失,因为 MDC 是 ThreadLocal,不随线程池和进程边界传播。
第三,sessionId 与 threadId 没有稳定语义。会话被反复拆成新线程,History 读不到,Admin 里也串不起来。
第四,模型耗时、Token 用量、Tool 调用、RAG 命中、Graph Node 耗时、远程 Agent 调用这些关键信号根本没有出口,Actuator 里只有 JVM 和 HTTP 的基础指标。
所以本篇的完成标准写成四条,可验证、可复现:
- 给定一个失败请求的 traceId,能还原出从 HTTP 入口到最内层调用的完整调用树。
- 这棵树上的模型耗时 / Token、Tool 调用、RAG 检索、Graph Node、远程 Agent 每一段都有明确的开始、结束和结果。
- 失败请求能被归入明确的失败分类,而不是统一的「Exception」。
- 一条失败请求能定位到模型、Tool、RAG、Graph Node 或远程 Agent 五层中的某一层,并给出该层的原始证据。
失效不在跑
失明才要命
标准立四行
验收方有据
03、原理
先讲清楚一个前提:可观测性不是「日志更多」,而是「在不理解内部实现的前提下,仅凭外部输出就能推断内部状态」。加日志只是手段之一,它既不充分也不必要。
这里有一个反直觉判断:埋点数量的增加,不等于可观测性的提高。真正决定上限的是身份对齐能力。一百条没有共同 traceId 的日志,是一百个孤立事实;一百条共享同一 traceId 的日志,是一条证据链。前者让你更吵,后者让你更准。
第二个反直觉判断:最可靠的打点位置不在业务代码里,而在框架的 Hook 与 Observation 层。业务代码会改、会被绕过、会被复制粘贴到另一个分支;而 ChatClient 调用模型、ReactAgent 执行 Tool、Graph Runtime 推进节点,这些调用点是必经之路。站在必经之路上打点,才能保证「只要它跑了,就一定有记录」。
基于这两个判断,本篇真正依赖的机制只有四组。
第一组是 Micrometer Observation。ObservationRegistry 与 ObservationHandler 构成一条统一的观测抽象:Observation 负责一次操作的开始、结束、异常与上下文键值,Handler 决定这条记录最终去哪。Spring AI 已经把 ChatModel、EmbeddingModel、VectorStore、Tool 调用接进了这套体系,所以我们不需要自己造轮子,只需要把业务语义补进去。
第二组是 Agent Hooks。ReactAgent 在 Agent、Model、Tool 三个层次提供了回调扩展点,业务侧可以在不改主流程的前提下采集「谁在什么时刻调用了什么、结果如何」。Hook 的价值在于它是结构化的:回调本身就带着这次调用的身份信息,比在业务代码里手写 log.info 可靠得多。具体回调接口名与签名以 v1.1.2.2 源码为准,需按当前官方文档核验。
第三组是 Graph event / stream。Graph Runtime 在节点执行、边跳转、状态变更时会产生事件与流式输出,节点级别的耗时和失败节点从这里取最准。相比在节点函数内部埋点,从事件流里取的好处是不侵入节点实现。
第四组是 Admin。spring-ai-alibaba-admin 在官方主仓库 v1.1.2.2 分支下有对应目录,提供运行时可视化、tracing、评估与 MCP 管理几类能力,trace 采集走的是 OpenTelemetry 体系。它的安装方式、依赖组件与端口配置以仓库当前 README 为准,需按当前官方文档核验。
然后是本篇最关键的一个设计决定:统一身份四元组。
- traceId:一次请求的唯一标识,跨线程、跨进程传递。
- projectId:一个数字人项目,用来做面板维度的聚合与隔离。
- sessionId:一次会话,用户视角的「这一次对话」。
- threadId:Agent 的对话线程,对应 Memory 层的会话标识,决定 History 能不能读回来。
四者缺一不可。只有 traceId,你能定位技术故障,但定位不到「哪个数字人、哪次会话」;只有 sessionId,你能定位用户体验问题,但追不进具体是哪一次模型调用出了错。
最后是失败分类。这是我强烈建议在写第一行可观测代码时就一起定下来的东西,因为事后补分类意味着要重跑。本文使用的分类集合是:MODEL_TIMEOUT、MODEL_ERROR、TOOL_ERROR、RAG_EMPTY、RAG_ERROR、GRAPH_NODE_ERROR、REMOTE_AGENT_ERROR、INPUT_INVALID、UNKNOWN。分类的意义在于它能聚合:成功率告诉你「有问题」,失败分类告诉你「问题在哪一类」。
埋点非越多
对齐才成链
身份先于量
证据后于行
04、架构
[输入] 用户请求 / API 调用 / LiveKit 事件
|
v
[模块] Web 层 → RequestContextFilter → ReactAgent(Hooks) → Model / Tool / RAG / Graph Node / Remote A2A
|
v
[数据/状态] 身份四元组(traceId/projectId/sessionId/threadId) + Observation Span + Metrics + 失败分类
|
v
[处理] Observation 打点 → OTLP 上报 → Admin(runtime visualization / tracing / evaluation / MCP 管理)
|
v
[输出] 调用树还原 + 失败定位 + 指标看板 + 运营数据
这条链路里,RequestContextFilter 是身份的诞生地:请求进来的第一件事就是确定 traceId、projectId、sessionId、threadId,并把它们绑定到线程上下文与 MDC。往后的每一层,无论是 Hook 还是 Observation,都从这个上下文里取身份,而不是自己生成。
中间那层是观测数据的生产区。模型调用、Tool 调用、RAG 检索、Graph Node、远程 A2A 各产生一段带身份的记录,这些记录之所以能拼成树,是因为它们共享同一个 traceId 并且有父子关系。
下游是消费区。Admin 负责把这棵树渲染出来,让工程师用眼睛看;Actuator 与 Micrometer 负责把指标导出去,让告警和看板能用;失败分类负责让数据可以按类型聚合。
边界要说清楚:可观测性不改变业务逻辑,不负责重试,不负责降级。它只做两件事——记录,以及让记录可被还原。把重试和降级塞进观测层,是很多团队做砸这块的第一个坑。
收益也是明确的:一次失败请求的定位时间从「小时」量级压到「分钟」量级;同类失败可以聚合;上线前的评估有数据可依。
代价有三个。第一是额外依赖与部署成本,Admin 不是零依赖的,它需要配套的存储与服务组件。第二是采样成本,全量采集在高并发下会显著增加存储与网络压力,需要按环境设置采样率。第三是侵入性——虽然 Hook 层比业务层干净,但加 Hook 仍然意味着代码改动,这部分改动需要测试覆盖。
适用条件是:一次请求跨越两个以上组件,或者跨越进程边界。如果你的 Agent 只有一个模型调用、一条线程、一个进程,那 log.info 确实够用,不必上这套。
入口先立号
逐层皆透传
出则同一树
归则同一因
05、实战一次
先做最小可跑版本 V1:让一条请求在日志里带上完整的身份四元组,并把模型调用和 Tool 调用打上 Observation,在 Actuator 里能看到指标,在 OTLP 后端能看到 span。Admin 的接入留到第07章,因为 V1 必须先证明身份是通的。
本节代码未在当前环境实测,以下为预期结果。
环境与版本:JDK 21 LTS、Spring Boot 3.x、Spring AI Alibaba v1.1.2.2、Micrometer Tracing + OpenTelemetry Exporter。
依赖(pom.xml 片段):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
配置(application.yml 片段):
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
tags:
application: digital-human
tracing:
sampling:
probability: 1.0
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
核心实现第一步,身份载体:
package com.example.digitalhuman.observability;
public record RequestContext(
String traceId,
String projectId,
String sessionId,
String threadId) {
private static final ThreadLocal<RequestContext> HOLDER = new ThreadLocal<>();
public static RequestContext current() {
return HOLDER.get();
}
public static void bind(RequestContext ctx) {
HOLDER.set(ctx);
}
public static void clear() {
HOLDER.remove();
}
}
第二步,入口过滤器,负责生成或继承 traceId 并写入 MDC:
package com.example.digitalhuman.observability;
import io.micrometer.tracing.Tracer;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class RequestContextFilter extends OncePerRequestFilter {
private final Tracer tracer;
public RequestContextFilter(Tracer tracer) {
this.tracer = tracer;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = resolveTraceId();
String projectId = headerOr(request, "X-Project-Id", "unknown");
String sessionId = headerOr(request, "X-Session-Id", "unknown");
String threadId = projectId + ":" + sessionId;
RequestContext ctx = new RequestContext(traceId, projectId, sessionId, threadId);
RequestContext.bind(ctx);
MDC.put("traceId", traceId);
MDC.put("projectId", projectId);
MDC.put("sessionId", sessionId);
MDC.put("threadId", threadId);
response.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
RequestContext.clear();
MDC.clear();
}
}
private String resolveTraceId() {
if (tracer.currentSpan() != null) {
return tracer.currentSpan().context().traceId();
}
return UUID.randomUUID().toString().replace("-", "");
}
private String headerOr(HttpServletRequest request, String name, String fallback) {
String value = request.getHeader(name);
return value == null || value.isBlank() ? fallback : value;
}
}
第三步,线程池上下文传播,这一步不做,后面必踩第06章的坑:
package com.example.digitalhuman.observability;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
public class ContextAwareTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
RequestContext ctx = RequestContext.current();
return () -> {
try {
if (ctx != null) {
RequestContext.bind(ctx);
MDC.put("traceId", ctx.traceId());
MDC.put("projectId", ctx.projectId());
MDC.put("sessionId", ctx.sessionId());
MDC.put("threadId", ctx.threadId());
}
runnable.run();
} finally {
RequestContext.clear();
MDC.clear();
}
};
}
}
第四步,观测打点工具类,统一补上身份与失败分类:
package com.example.digitalhuman.observability;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Component;
import java.util.function.Function;
import java.util.function.Supplier;
@Component
public class ObservedOperation {
private final ObservationRegistry registry;
public ObservedOperation(ObservationRegistry registry) {
this.registry = registry;
}
public <T> T run(String name, String layer, Supplier<T> action, Function<Throwable, FailureType> classifier) {
RequestContext ctx = RequestContext.current();
Observation observation = Observation.createNotStarted(name, registry)
.lowCardinalityKeyValue("layer", layer)
.lowCardinalityKeyValue("application", "digital-human")
.highCardinalityKeyValue("project.id", ctx == null ? "unknown" : ctx.projectId())
.highCardinalityKeyValue("session.id", ctx == null ? "unknown" : ctx.sessionId())
.highCardinalityKeyValue("thread.id", ctx == null ? "unknown" : ctx.threadId())
.highCardinalityKeyValue("trace.id", ctx == null ? "unknown" : ctx.traceId());
observation.start();
try (Observation.Scope scope = observation.openScope()) {
return action.get();
} catch (RuntimeException ex) {
FailureType type = classifier.apply(ex);
observation.lowCardinalityKeyValue("failure.type", type.name());
observation.error(ex);
throw ex;
} finally {
observation.stop();
}
}
}
第五步,失败分类枚举:
package com.example.digitalhuman.observability;
public enum FailureType {
MODEL_TIMEOUT,
MODEL_ERROR,
TOOL_ERROR,
RAG_EMPTY,
RAG_ERROR,
GRAPH_NODE_ERROR,
REMOTE_AGENT_ERROR,
INPUT_INVALID,
UNKNOWN
}
第六步,把 Hook 挂到 ReactAgent 上。这里的方法名以 v1.1.2.2 源码为准,需按当前官方文档核验:
package com.example.digitalhuman.agent;
import com.example.digitalhuman.observability.ObservedOperation;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.stereotype.Component;
@Component
public class DigitalHumanAgentFactory {
private final ChatModel chatModel;
private final ObservedOperation observed;
public DigitalHumanAgentFactory(ChatModel chatModel, ObservedOperation observed) {
this.chatModel = chatModel;
this.observed = observed;
}
public Object build(String name) {
return ReactAgent.builder()
.name(name)
.model(chatModel)
.hooks(new ObservabilityHook(observed))
.build();
}
}
package com.example.digitalhuman.agent;
import com.example.digitalhuman.observability.FailureType;
import com.example.digitalhuman.observability.ObservedOperation;
public class ObservabilityHook {
private final ObservedOperation observed;
public ObservabilityHook(ObservedOperation observed) {
this.observed = observed;
}
public void onModelCall(String modelName, Runnable action) {
observed.run("genai.model.call", "model", () -> {
action.run();
return null;
}, throwable -> FailureType.MODEL_ERROR);
}
public void onToolCall(String toolName, Runnable action) {
observed.run("genai.tool.call", "tool", () -> {
action.run();
return null;
}, throwable -> FailureType.TOOL_ERROR);
}
}
启动与请求:
curl -X POST http://localhost:8080/api/digital-human/chat \
-H "Content-Type: application/json" \
-H "X-Project-Id: p-1001" \
-H "X-Session-Id: s-9f2c" \
-d '{"message":"把这个月的订单按区域拆一下"}'
示例输出(日志):
2025-xx-xx 16:03:11.482 INFO [digital-human,4f2a9c1b8d3e4f56,4f2a9c1b8d3e4f56] [http-nio-8080-exec-1] c.e.d.web.ChatController : traceId=4f2a9c1b8d3e4f56 projectId=p-1001 sessionId=s-9f2c threadId=p-1001:s-9f2c
验证方式有三条。第一,手工触发一次 Tool 调用,观察 Tool 执行线程的日志里 traceId 是否与入口一致。第二,访问 /actuator/metrics/genai.model.call 与 /actuator/metrics/genai.tool.call,确认计时器已经产生。第三,把 OTLP endpoint 指向任意兼容后端,确认一次请求能看到完整的 span 树。
V1 跑通之后的遗留问题也很清楚:身份只在日志和 span 里,没有可视化的消费面;Tool 在自定义线程池里执行时身份会丢;远程 Agent 调用是跨进程的,header 还没透传。这三个问题留到后面两章。
先跑一条线
再谈一座台
最小可验证
方敢往下改
06、排查
V1 上线到测试环境之后,出现了三个必须按证据走的现象。
现象一:Tool 执行那段日志的 traceId 是空的
怀疑:MDC 没有跨线程传播。
检查:在 Tool 的实现里打印 Thread.currentThread().getName() 和 MDC.get("traceId")。
证据:Agent 主线程是 http-nio-8080-exec-1,Tool 执行线程是 digital-human-tool-2;前者 traceId 有值,后者为 null。
根因:MDC 底层是 ThreadLocal,它只对当前线程可见。ReactAgent 的 Tool 调用走的是独立线程池,线程切换时上下文没有跟着走。
修复:给该线程池配置 ContextAwareTaskDecorator(第05章已经给出实现),在任务提交时捕获上下文、执行时重新绑定、结束时清理。
错误尝试: 一开始的做法是把 traceId 存进一个 public static String CURRENT_TRACE_ID 静态字段,在 Filter 里赋值,在 Tool 里直接读。为什么错误——静态字段是全进程唯一的一份,线性执行时看起来正确,一旦并发就互相覆盖。更糟的是它不会报错:你只会看到两个并发请求的日志互相串号,然后开始怀疑人生。ThreadLocal 存在的意义正是隔离这种「每个线程一份」的状态,绕开它等于把并发 bug 请进门。
现象二:同一通会话在 Admin 里被拆成了多个 thread
怀疑:threadId 的生成方式不对。
检查:抓三条同 sessionId 的请求,看它们写入的 threadId 是否一致。
证据:三条请求的 sessionId 都是 s-9f2c,traceId 各不相同,而 threadId 因为用了 traceId 拼接,所以三个都不一样。
根因:把 threadId 当成「一次请求」的标识来生成,而它实际是「一个会话线程」的标识,决定 Memory 能否读回历史。
修复:threadId 由 projectId 与 sessionId 稳定派生(如 p-1001:s-9f2c),同一会话内保持不变;需要支持多会话分支时,再由业务显式指定 threadId。
现象三:远程 A2A 调用的下游链路断在边界上
怀疑:跨进程调用没有透传身份头。
检查:在远程 Agent 一侧打印接收到的所有 header。
证据:下游只收到了 Content-Type,没有任何 trace 相关信息。
根因:同步 HTTP 客户端不会自动继承本地上下文,trace 传播需要在出站请求上显式注入。
修复:为远程调用加一层出站拦截器,把 traceparent 与 X-Project-Id、X-Session-Id、X-Thread-Id 写入请求头;下游入口过滤器读到这些头时优先继承,而不是重新生成。
三条链走完,可以得到一个明确的结论:身份丢失从来不是「日志没打够」,而是「上下文没传够」。传播机制不解决,打多少点都白搭。
现象未必真
怀疑须有据
证据落一处
根因自现形
07、优化
V2 严格基于第06章的三条根因形成。
根因一,MDC 与线程池之间没有传播契约。修改:统一用线程池包装策略,所有进入 Agent 异步调用的线程池都走 ContextAwareTaskDecorator;同时把上下文绑定收敛到 RequestContext 一个入口,禁止业务代码直接操作 MDC。原因:上下文入口越少,丢失点越少。新行为:Tool、异步节点、流式回调的日志里 traceId 与入口一致。验证:并发发 20 条请求,逐条比对 Tool 日志与入口 traceId 是否一一对应。
根因二,threadId 语义不稳定。修改:threadId 由 projectId + sessionId 稳定派生,并作为 Memory 的会话键落库。原因:观测标识必须与业务标识同源,否则两套 ID 永远对不上。新行为:同一会话的多轮请求归入同一 thread,History 可连续读回。验证:同一 sessionId 连续发三轮请求,确认三个 traceId、一个 threadId。
根因三,跨进程身份未透传。修改:出站拦截器写入 traceparent 与三个业务头,入站过滤器优先继承。原因:trace 的价值在于跨边界仍在。新行为:远程 Agent 的日志与本地调用进入同一棵调用树。验证:观察一次跨 A2A 调用,确认上下游 span 的 traceId 相同。
在这三条修复之上,V2 增加了三块能力。
第一块是 Observation 分层命名规范。统一使用 genai.agent.run、genai.model.call、genai.tool.call、genai.rag.retrieve、genai.graph.node、genai.a2a.call 六类名字,低基数标签只放层级与状态,高基数标签放 project.id、session.id、thread.id、trace.id。这样做的原因是:指标要能聚合,标签就必须控制基数;链路要能定位,身份就必须完整。二者放在不同的标签层,是唯一能同时满足的做法。
第二块是失败分类落地。所有观测入口统一接收失败分类器,把异常映射到 FailureType 枚举,并作为低基数标签写入。这样在指标层可以直接按 failure.type 聚合,在 Admin 层可以按类型筛选。相比只记录「是否成功」,分类让数据从「现象」变成「线索」。
第三块是 Admin 接入。把 OTLP endpoint 指向 Admin 的采集端,运行时可视化页面即可看到一次请求从 HTTP 入口到模型、Tool、RAG、Graph Node 与远程 Agent 的完整调用树;评估与 MCP 管理能力在同一个面板里。Admin 的部署组件与端口配置以仓库当前 README 为准,需按当前官方文档核验。
三个标杆项目在这块给的是组合思路,而不是可直接抄的 API:DataAgent 展示了 StateGraph 编排下节点级观测与 Sandbox、MCP、RAG 的组合,节点观测的重要性由此凸显;DeepResearch 把可观测接到了 Langfuse 上,说明多 Agent 场景下「用现成的观测后端」比自研面板更划算;Lynxe 强调高确定性通用 Agent 的结构,它提醒我们观测数据本身也要有确定的字段契约,否则面板再漂亮也复现不了。
根因既已明
改法不须多
改在关键处
新行为自成
08、演进
[同一输入:一次失败的对话请求]
|
+--[V1] 日志有 traceId,指标只有 JVM 与 HTTP 层
| 代价:Tool 线程丢身份,跨进程断链,失败只能靠人读日志
|
+--[V2] 身份四元组贯穿六层,Observation 分层,失败分类可聚合,Admin 可视化
| 代价:新增 Admin 依赖组件、采样配置与 Hook 侵入,需要测试覆盖
|
[Trade-off]
得到:一条失败请求可定位到具体层级,同类失败可聚合
失去:零依赖的轻量部署,以及一部分「随手加日志」的自由度
适用边界:单线程单模型的小系统不必上;跨组件、跨线程、跨进程的 Agent 系统价值明显
正确性上,V1 依赖人工按时间戳对齐,本质上是一种概率推断;V2 依赖显式身份贯穿,判定是同一次请求还是不同请求是一个确定性的操作。这不是效率差异,是正确性差异。
稳定性上,V1 在并发升高时会急剧退化——不是变慢,是变错,日志会串号;V2 的上下文隔离由 ThreadLocal 与显式传播保证,并发不会污染身份。
复杂度上,V2 明显更高。新增了过滤器、装饰器、观测工具类、分类枚举、Admin 组件。这些代码本身不难,但它们要求团队遵守约定:所有异步入口必须带传播,所有观测必须带分类。约定一旦被破坏,观测质量会静默下降,这是它最大的运维风险。
成本上,主要开销在两处:Admin 相关组件的部署与存储,以及 trace 数据的采集量。前者是一次性投入,后者需要按环境设置采样率,测试环境可以全采,生产环境需要评估存储承受能力后再定策略。
适用范围上,V2 适合「一次请求跨越两个以上组件或进程」的系统;如果只是单体里一个模型调用,V1 足够,不要为了架构美感引入额外组件。
遗留问题有三个:一是采样策略尚未按环境分级;二是评估(evaluation)能力目前只接了 Admin 的入口,还没有形成定期跑的评估集;三是 A2A 场景下的身份透传只在同步 HTTP 上验证过,异步消息通道尚未覆盖。
同入不同路
取舍各有时
得者在其明
失者在其重
09、洞见
9.1 身份先行,指标其次
可观测性建设最容易走错的一步,是先上监控大盘,再想「这些数字怎么关联到一次请求」。正确的顺序是反过来的:先把 traceId、projectId、sessionId、threadId 这四元组钉死在入口,再谈采集什么。没有身份的数字,只是好看。
反直觉判断:在可观测性上,投入产出比最高的动作不是增加采集项,而是减少身份的生成点。生成点越少、来源越单一,链路越可信。
9.2 打点位置决定可观测性的天花板
业务代码里埋点,测得准但管不住;框架层打点,测得少但测得全。这一掌我们把打点位置从 Controller 移到了 Hook 与 Observation 层,代价是回调接口要跟着框架走,收益是「只要它跑了就一定有记录」。
反直觉判断:在业务代码里埋点越多,链路反而越不可信。因为埋点是人工的,人工就有遗漏、有先后不一、有分支覆盖不齐。必经之路上的少量打点,胜过主干道旁的密集旗子。
9.3 失败分类是运营资产,不是调试细节
第02章我们把完成标准写成四条,其中第三条是「失败请求能被归入明确的失败分类」。这条看起来最像形式主义,实际价值最高:它让失败从「一堆异常」变成「几个可聚合的品类」。有了品类,才能谈趋势、谈告警、谈优先级。
反直觉判断:成功率是给上面看的,失败分类是给自己用的。只看成功率,你永远只能回答「最近不太好」;有了分类,你才能回答「最近 Tool 超时变多了」。
9.4 Admin 不是后台,是证据台
很多团队把 Admin 当运维后台,只在出事的时候打开。更合适的定位是证据台:每一次线上问题都应该沉淀成一条可回放的 trace,评估集也应当从这些真实失败中长出。标杆项目的价值不在它们的页面长什么样,而在它们把 StateGraph 节点、Sandbox 执行、MCP 工具、Hybrid RAG 这些环节都留下了可复查的记录。没有记录的组合,出问题时无法归因。
9.5 观测的边界是「记录」,不是「干预」
一旦把重试、降级、熔断塞进观测层,观测层就变成了第二个业务层,它会改写真实执行路径,让证据失真。观测层应当只做两件事:记录事实,暴露事实。干预要有独立的、可观测的位置。
洞见非新词
皆从旧证来
一言可复用
胜读十篇文
10、系统落地
原来有的:一个能启动的 Spring Boot 数字人服务,注册登录、项目 CRUD、Web 运行页、LiveKit 实时交互、ReactAgent 与 Graph Runtime,以及一堆散落在各处的 log.info。
本篇新增的:入口处的身份四元组生成与绑定、跨线程的上下文传播、六类分层的 Observation 打点、一份失败分类枚举、出站与入站的身份透传约定,以及 Admin 的接入点。
现在能做的:拿到一个失败请求的 traceId,可以还原它从 HTTP 入口到模型、Tool、RAG、Graph Node、远程 Agent 的完整路径,看到每一段的耗时与结果,看到它失败在哪一层、属于哪一类失败。
还缺的:采样策略没有按

浙公网安备 33010602011771号