AgentScope第29式·登峰评测
AgentScope第29式·观测与评测
能力齐了不等于能上线,差的正是观测与评测这两把尺子。
[读者] 已把 AgentScope Java 用到 Memory / Subagent 的团队
[痛点] 功能都能演示,却说不清灰度之后是否可控
[现在读] 前 28 式能力已经齐了,缺最后一段工程收束
[读完] 拿到可观测、可评测、可灰度的一套落地骨架
[旧方案] Demo 跑通 + 人肉看日志 + 拍脑袋上线
|
v
[新需求] 要灰度、要回归、要能回答这次改动到底变好还是变坏
|
v
[冲突] 链路跨 Middleware / Tool / Subagent,既没有统一 Trace,也没有可重复的评测集
|
v
[后果] 出问题只能整包回滚,能力越多反而越不敢发
我是老李。前面二十八式,我们沿着 AgentScope Java 2.x 把选型、环境、Agent 内核、Tool/MCP、权限与状态、Harness、Workspace、Memory、Skill、Plan、Sandbox、多 Agent 一路走完,宿主一直是那个最小交互数字人 Demo——注册登录、数字人项目 CRUD、项目配置、Agent 最小配置、/run/{projectId} 运行页、LiveKit 实时会话,这条链一次都没换过。
到这一式,Demo 已经不「小」了。一个运行页背后可能挂着 Realtime Bridge、若干 Tool、若干 Memory、一两个 Subagent。功能演示越来越好看,但发版前那句话越来越说不出口——这次改动,到底让系统变好了还是变坏了。
没有刻度的系统,凭什么发版?
刻度该刻在 Middleware、Harness,还是外部评测集?
刻度刻错了,回滚的是代码,还是判断标准本身?
01、故事
周一早上九点,产品在群里问了一句:昨天改了开场白和 systemPrompt,今天用户会话是不是变差了?
我打开服务器,日志滚了三千行。里面有 Tool 调用记录,有 LLM 返回,有 LiveKit 的连接事件,还有若干处 log.info("agent reply = {}", reply)。看了二十分钟,我给出的结论是:看起来没什么异常。
「看起来没什么异常」,就是问题的全部。一个能回答「数字人今天说了多少句话」的系统,回答不了「数字人今天说的话为什么变差了」。这两者中间隔着的,不是更多日志,而是一条能从头连到尾的因果链,和一把不会随人心情变化的尺子。
任务是:在 chapter/29-observe-evaluate 分支上,把前 28 式积累的全部能力收束成一个可度量、可回归、可灰度的完整工程,并交付四件东西——一份生产检查表、一套自动化回归评测、一张最终架构图、一次故障演练。做完这四件,这个从第 3 式冻结下来的最小交互数字人 Demo,才第一次具备「能从 Demo 进入灰度」的资格。
难度在于,链路是跨进程的:Web 运行端到 LiveKit,到 Realtime Bridge,再到 AgentScope Java,最后到 LLM。任何一段断掉,你看到的都只是「回答变慢了」,而不是「哪一段变慢了」。
数日志千行
看不清链路
一句好像正常
换不来灰度
02、问题
旧方案很朴素:日志加人眼,加全量发布。前 28 式里我们一直这么干,因为那时系统只有一个 Agent、两个 Tool,任何问题看一眼日志就能定位。
能力堆上来之后,旧方案开始失效,具体表现是三件事同时发生。
第一件,Trace 断链。 主 Agent 调用 Subagent,Subagent 再调用 Tool,三个环节各自都有记录,但它们之间没有共同标识,所以你无法回答「这一轮总共花了多少 Token、卡在哪一步」。业务影响是:优化完全靠猜。
第二件,指标口径不清。 有人把 Token 数记成 input 加 output 之和,有人只记 output;有人统计 Tool 调用次数,有人统计 Tool 成功次数。结果是同一个版本,两个同事给出两个相反的结论。业务影响是:数据不可比,讨论变成争论。
第三件,评测集不存在。 每次改 prompt、换模型、调工具描述,验证方式都是「我拉了五个问题,感觉还行」。业务影响是:没有人敢批准灰度,也没有人敢批准回滚,因为两边都没有依据。
技术上,这三件事指向同一个根因:系统缺少一个独立于业务代码的观测与评测层。它必须在 Harness 之上横切,而不是散落在每个业务类里。
本篇的完成标准是可验证的,一共四条:
- 任意一条用户会话,都能拿到一个贯穿 Web 运行端、Bridge、Agent、Tool、Subagent 的 Trace,且父子关系完整;
- 六个核心指标有唯一、写进代码的口径定义,不依赖口头约定;
- 一次代码改动之后,能自动跑出一份版本间可对比的评测报告;
- 一次人为故障演练中,能靠观测数据在约定时间内定位到具体环节。
完成标准里没有一条写「性能提升多少」。因为观测与评测的第一版,交付的是判断力,不是性能。
无观测不上线
无评测不发版
口径先定死
再谈提效果
03、原理
先把四个词的一次性边界说清楚,后面不再展开。Harness 是把 Agent 真正跑起来的那层壳,负责会话生命周期、中间件编排、状态与工具装配;Middleware 是挂在 Harness 调用链上的横切扩展点,能在不改业务 Agent 的前提下插入观测、鉴权、限流;Subagent 是主 Agent 委派出去的子 Agent,它有自己的执行上下文,因此也最容易在观测里变成孤儿;Realtime Bridge 在本系列里始终保持最薄,只做媒体与消息适配。
现在说本篇唯一需要的那个反直觉判断。
反直觉判断:模型、Tool、Subagent 三层各自埋点,每一层看起来都是完整的,合起来却一定是断的。
为什么?因为「完整」和「连通」是两回事。三层各自打点,你得到的是三份互不相关的记录;只有当你先想清楚谁是谁的父节点,再埋点,你才得到一条因果链。所以观测的第一原则不是多打点,而是先把关系定下来。
这也是为什么本节选的不是自研日志框架,而是 OpenTelemetry:它的价值不在于有多少 exporter,而在于它用一个强制性的父子模型,逼你在写第一行埋点代码之前就回答「这一步属于谁」。一旦这个模型定下来,采样、导出、告警、离线聚合才有共同语言。
评测遵循同一套逻辑。如果评测集只测最终答案对不对,你就永远无法区分「模型不行」和「工具不行」。所以本篇把评测按能力分层,六个指标对应三种粒度:
- 单点粒度:知识问答准确率、Tool 参数正确率、Tool 成功率;
- 策略粒度:Permission 正确率;
- 链路粒度:长任务成功率、Subagent 协作成功率。
这六个指标的定义必须写进代码,不能写在文档里。写在文档里的口径,三个月后一定会出现第二种解释。
最后一条原理:观测量和评测输入应该来自同一个数据源。评测不应该是另起一套测试脚本,它应该消费和生产同一份 Trace 结构——线上观测到的失败样本,能直接变成下一次回归的用例;回归跑出来的报告,能直接回答线上观测提出的问题。这两条路一旦分叉,评测集就会慢慢退化成一份和线上无关的表演。
Trace 是骨架
指标是刻度
评测是标尺
三者缺一不可
04、架构
[输入] 用户消息 / LiveKit 音频事件 / 离线回归评测请求
|
v
[模块] Harness Agent(Middleware 链:Trace / 评测采样 / 权限)
|
v
[数据/状态] Session 状态 + Trace 上下文 + 原始 Span / Metric
|
v
[处理] OTLP 导出到观测后端;离线聚合进评测判分器并产出回归报告
|
v
[输出] 灰度放行结论 / 回滚决策 / 上线检查表
边界。 观测层只做采集、聚合和导出,不参与业务决策;评测层只做判分和比对,不改动 Agent 行为;权限、限流、状态这些属于前 28 式已经落地的东西,本篇只把它们纳入观测口径,不重新设计。
收益。 一次改动带来的影响,从「靠感觉」变成「有版本间可比数据」;一条异常链路,从「翻日志」变成「查 Trace 上的关键路径」。
代价。 埋点口径需要长期维护,评测集需要持续补充失败样本。这两件事都是纯成本,没有任何一条业务需求会主动要求你做,所以必须靠检查表强制。
适用条件。 系统已经进入多 Tool、多 Subagent 阶段;发版频率高于每周一次;出现过分不清责任方的性能或质量投诉。
不适用条件。 还停留在单 Agent 单 Tool 的探索期;团队只有一到两人;一个月只碰一次这个项目。这种情况下,日志加人眼是更划算的选择,此时引入完整观测与评测,大概率会变成没人维护的空壳。
官方源码位置。 观测挂点对应 Harness 层的中间件机制:agentscope-harness 模块下的 AgentTraceMiddleware(见 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AgentTraceMiddleware.java )与 Harness 入口 HarnessAgent(见 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java )。
需要说清楚的是:本系列基线固定在 v2.0.3。上面两个类在 v2.0.3 的职责与可覆写点,请以该 tag 下的实际源码为准;main 分支后续可能调整,不能把 main 上的行为倒推成 v2.0.3 的能力。本篇的自定义埋点,选择以自定义 Middleware 的方式接入,而不是修改框架内部实现。
Middleware 挂点
Harness 收口
导出走 OTLP
口径留本地
05、实战一次
环境与版本。 JDK 17 及以上,Maven 3.8 及以上,教程版本基线 AgentScope Java v2.0.3,OpenTelemetry SDK 使用与你所用版本相匹配的稳定版(具体版本号请按当前官方文档核验)。本章代码位于 chapter/29-observe-evaluate 分支,从第 28 式分支继续演进。
依赖。 引入 OpenTelemetry 三件套,只做 API、SDK 与 OTLP 导出,不引入任何厂商绑定:
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
配置。 观测端点和采样率全部走配置,禁止硬编码:
otel:
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
resource:
service.name: digital-human-agent
traces:
sampler: parentbased_traceidratio
ratio: ${OTEL_TRACES_SAMPLER_RATIO:1.0}
eval:
dataset: classpath:eval/cases-answer.jsonl
report-dir: target/eval-report
核心实现之一:观测记录器。 这里只给工具调用的最小闭环,模型调用与 Subagent 委派复用同一套结构。
package com.demo.obs;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.LongHistogram;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import java.util.function.Supplier;
public final class AgentObservability {
private static final AttributeKey<String> ATTR_TOOL = AttributeKey.stringKey("tool.name");
private final Tracer tracer;
private final LongCounter toolCalls;
private final LongCounter toolErrors;
private final LongHistogram toolLatencyMs;
public AgentObservability(OpenTelemetry otel) {
this.tracer = otel.getTracer("digital-human-agent");
Meter meter = otel.getMeter("digital-human-agent");
this.toolCalls = meter.counterBuilder("agent.tool.calls").build();
this.toolErrors = meter.counterBuilder("agent.tool.errors").build();
this.toolLatencyMs = meter.histogramBuilder("agent.tool.latency").ofLongs().build();
}
public <T> T observeTool(String toolName, Supplier<T> body) {
Span span = tracer.spanBuilder("tool." + toolName).startSpan();
span.setAttribute("tool.name", toolName);
long start = System.nanoTime();
try (Scope ignored = span.makeCurrent()) {
T result = body.get();
toolCalls.add(1, Attributes.of(ATTR_TOOL, toolName));
return result;
} catch (RuntimeException e) {
toolErrors.add(1, Attributes.of(ATTR_TOOL, toolName));
span.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
throw e;
} finally {
toolLatencyMs.record((System.nanoTime() - start) / 1_000_000L, Attributes.of(ATTR_TOOL, toolName));
span.end();
}
}
}
这段代码的关键不在指标名,而在 try (Scope ignored = span.makeCurrent())。这一行决定了后续所有子调用能不能挂到当前 span 下面。异步场景下,Scope 不会自动跨线程,必须显式用 Context.current().wrap(...) 包装任务,否则就会出现第 06 章要排查的孤儿 span。
核心实现之二:评测数据结构与运行器。 为便于阅读,下面几个类型放在同一片段里,落地时每个 public 类型单独成文件。
package com.demo.eval;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record EvalCase(
String id,
String projectId,
String question,
List<String> expectTools,
Map<String, Object> expectArgs,
Set<String> mustMention,
Set<String> mustNotMention,
int maxSteps) {}
public record ToolInvocation(String name, Map<String, Object> args, boolean ok) {}
public record PermissionDecision(String tool, boolean allowed) {}
public record AgentRunTrace(
String traceId,
List<ToolInvocation> tools,
List<PermissionDecision> permissions,
String finalAnswer,
int steps,
long latencyMs,
long promptTokens,
long completionTokens) {}
public record EvalReport(
double answerAccuracy,
double toolArgAccuracy,
double toolSuccessRate,
double permissionAccuracy,
double longTaskSuccess,
double subagentSuccess,
int total,
int failures) {}
public interface AgentUnderTest {
AgentRunTrace run(String question, String projectId, int maxSteps);
}
public interface Scorer {
double score(EvalCase c, AgentRunTrace run);
}
package com.demo.eval;
import java.util.ArrayList;
import java.util.List;
public final class EvalRunner {
private final AgentUnderTest agent;
private final List<Scorer> scorers;
private final List<EvalCase> cases;
public EvalRunner(AgentUnderTest agent, List<Scorer> scorers, List<EvalCase> cases) {
this.agent = agent;
this.scorers = scorers;
this.cases = cases;
}
public List<Double> runOnce() {
List<Double> scores = new ArrayList<>();
for (EvalCase c : cases) {
AgentRunTrace trace = agent.run(c.question(), c.projectId(), c.maxSteps());
double sum = 0.0;
for (Scorer s : scorers) {
sum += s.score(c, trace);
}
scores.add(sum / scorers.size());
}
return scores;
}
}
评测集样例。 用 JSONL,一行一个用例,便于追加失败样本:
{"id":"qa-001","projectId":"demo-1","question":"你们的数字人支持哪些主题色配置?","expectTools":["projectConfigQuery"],"expectArgs":{"projectId":"demo-1"},"mustMention":["主题色"],"mustNotMention":[],"maxSteps":4}
{"id":"tool-002","projectId":"demo-1","question":"把项目开场白改成欢迎来到深圳","expectTools":["updateProjectConfig"],"expectArgs":{"field":"greeting","value":"欢迎来到深圳"},"mustMention":["已更新"],"mustNotMention":["失败"],"maxSteps":6}
{"id":"task-003","projectId":"demo-1","question":"检查项目配置并生成一份开场白优化建议","expectTools":["projectConfigQuery"],"expectArgs":{},"mustMention":["建议"],"mustNotMention":[],"maxSteps":12}
启动与请求。 先起一个本地 OTLP 接收端,再跑回归:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
mvn -q -pl demo-eval test -Dtest=EvalRegressionTest
验证。 需要验证三件事,而不是一件事:一,Trace 里能看到 tool.xxx 的子 span,且父 span 正确;二,agent.tool.calls 与 agent.tool.errors 能被导出;三,回归报告能给出六个指标。
说明:本章代码未在当前环境实测,以下为预期结果的格式示意,不代表真实跑分:
cases=24 failures=0
answerAccuracy=0.xx
toolArgAccuracy=0.xx
toolSuccessRate=0.xx
permissionAccuracy=0.xx
longTaskSuccess=0.xx
subagentSuccess=0.xx
数字刻意留空:评测结果必须由你自己的环境和评测集跑出来,任何从别处抄来的分数都没有意义。
先落最小埋点
再定判分口径
样例宁少勿糊
跑通一遍再说
06、排查
证据链一:Subagent 的 span 变成孤儿。
现象:主 Agent 的 Trace 里能看到委派事件,但子 Agent 内部的 Tool span 单独成了一条 trace,traceId 与主链路不同。
怀疑:采样把子节点丢了。
检查:把采样率临时调到 1.0,重新跑一次同样的请求,孤儿 span 依然存在。这说明问题不在采样。
证据:对照 AgentTraceMiddleware 在 v2.0.3 中的实现(https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AgentTraceMiddleware.java ),以及 HarnessAgent 的执行入口(https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java ),可以确认 span 的父子关系依赖调用时的 Context 传播;再看我们自己的 Subagent 执行体,它被提交到了一个自建的线程池。
根因:我们在自定义 Middleware 里把子任务丢进了默认线程池,Context.current() 在子线程里是空的,于是子 Agent 开始了一条全新 trace。
修复:在提交任务前用 Context.current().wrap(runnable) 包装,或者统一使用受 Context 管理的调度器。修复后,子 Agent 的 span 正确挂在委派 span 之下,整条链路可以按耗时排序。
错误尝试: 我们的第一反应是把采样率从 1.0 降到 0.1,理由是「采样太密集可能丢数据」。这个方向是错的,原因在于采样的语义:采样决定的是整条 trace 要不要上报,它在链路入口就做完了,不会只丢弃某几个子节点。也就是说,孤儿 span 这个现象本身就排除了采样,它只可能来自父子关系没建立,或者 Context 没传下去。沿着错误方向折腾半天,只会把「以为的根因」和真正的根因混在一起。
证据链二:回归全绿,线上答非所问。
现象:一次 systemPrompt 调整后,回归评测全绿,灰度当天收到三条「答非所问」的反馈。
怀疑:模型能力退化。
检查:把判分明细逐条打出来,发现绝大多数用例的得分来源是 mustMention 关键词包含判断,而模型在回答里确实复读了关键词,但语义已经跑偏。
证据:抽查五条用例,三条出现了「关键词命中、语义偏离」的情况,比如用户问主题色配置,回答里出现「主题色」但描述的是一张背景图。
根因:判分器与业务目标不对齐。关键词包含是一种极低成本的判分方式,它衡量的是「有没有提到」,而不是「有没有答对」。再加上评测集样本高度同构,模型学会了应付这套判分。
修复:判分分三层——规则层校验 Tool 名、参数与权限;断言层校验必含与禁含;语义层在关键用例上引入 LLM 裁判,并单独统计语义层通过率,不与规则层混算。同时把这三条线上失败样本归档成新的评测用例,保证下次回归必然覆盖。
上游贡献判断。 按本系列的规则,逐项说明:
- 是否可在最小数字人 Demo 中复现:本章两处问题都发生在教程侧的自定义 Middleware 与判分器里,不依赖特定业务数据;
- v2.0.3 是否存在上游缺陷:本章未在
AgentTraceMiddleware与HarnessAgent的 v2.0.3 源码中定位到可归因的上游缺陷,具体行为仍需以该 tag 源码为准; - main 是否仍存在:未核验;
- 是否已有 Issue/PR:本章未检索到与上述现象直接对应的官方 Issue 或 PR,需现场核验;
- 当前结论:只能记录为观察点,不构成上游候选,不写「已提交」或「已合并」。
断链先看父亲
绿测先看断言
根因不在框架
常在自己上下文
07、优化
第 06 章的两条证据链指向两个不同的根因:一个是 Context 传播,一个是判分口径。V2 围绕这两点做三处改造。
改造一:把观测口径集中到一个常量类。 第 06 章的孤儿 span,部分是靠人肉比对才发现的;如果 span 名和属性名散落在各处,同类问题还会再犯。做法是把所有 span 名、指标名、属性键收进一个类,任何地方都只能引用常量,不再出现字符串拼接。
package com.demo.obs;
public final class ObsNames {
private ObsNames() {}
public static final String AGENT_RUN = "agent.run";
public static final String AGENT_DELEGATE = "agent.delegate";
public static final String LLM_CALL = "llm.call";
public static final String TOOL_PREFIX = "tool.";
public static final String M_TOOL_CALLS = "agent.tool.calls";
public static final String M_TOOL_ERRORS = "agent.tool.errors";
public static final String M_TOOL_LATENCY = "agent.tool.latency";
public static final String M_LLM_TOKENS = "agent.llm.tokens";
public static final String A_TOOL_NAME = "tool.name";
public static final String A_AGENT_NAME = "agent.name";
public static final String A_MODEL = "gen_ai.request.model";
}
新行为:任何新增埋点,先加常量再使用;代码评审时,如果 diff 里出现裸字符串形式的 span 名,直接打回。
改造二:判分三层分离,报告分层输出。 规则层的六个指标是确定性的,可以当作发布门槛;语义层带非确定性,只作为观察项,不参与放行判断。这解决的是「用一个不确定的尺子做确定的决策」的问题。
改造三:失败样本自动归档。 每次回归,失败用例连同它的 traceId 自动追加到 cases-regression.jsonl,并标记来源是线上还是离线。新行为是:评测集只增不减,且每一次灰度反馈都能进入下一次回归。验证方法是看下一次回归报告里,是否出现了上一次的失败用例编号。
判分拆三层
口径写进代码
失败样本留存
回归才可复现
08、演进
[同一输入] 一次数字人会话运行
|
+--[V1] 日志 + 人眼 + 全量发布
| 代价:不可回归、不可比对、不敢灰度
|
+--[V2] Trace + Metric + 分层评测集
| 代价:需要长期维护埋点口径与评测集版本
|
[Trade-off]
得到版本间可比较的判断依据;失去的是「随便改改就能发」的自由
逐项比较 V1 与 V2:
正确性。 V1 的正确性判断依赖个人经验,同一个现象两个人可能给出相反结论;V2 把六个指标钉死在代码里,结论可复现。代价是口径一旦定错,错得也更一致——所以口径本身需要评审。
稳定性。 V1 无稳定性可言,问题出现即回滚整包;V2 能定位到具体环节,回滚粒度可以更细,但引入了新的不稳定源:观测后端本身如果挂了,业务不应该受影响,这要求导出必须是异步且可丢弃的。
复杂度。 V1 里 Agent 代码就是全部;V2 多了一层中间件、一套指标口径、一份评测集和一条回归流水线。复杂度是真实上升的,不能假装它不存在。
成本。 直接的机器成本可以忽略;真正的成本是人力:评测集要持续补,失败样本要持续归档,衰减起来非常快。这是这套体系最容易死掉的地方。
适用范围。 多 Tool、多 Subagent、发版频繁的系统。单 Agent 单 Tool 的探索期不适用,见第 09 章第三条洞见。
遗留问题。 语义层判分仍是非确定性的;长任务成功率和 Subagent 协作成功率这两个指标对超时预算敏感,需要在评测集里显式声明 maxSteps,否则同一份用例在不同版本上会得出不同结论。
V1 靠眼睛
V2 靠刻度
灰度有依据
回滚有边界
09、洞见
9.1 埋点越多,可观测性可能越差
反直觉判断:在很多团队里,观测能力的提升与埋点数量并不正相关,甚至负相关。原因是每多一个埋点,就多一种口径;口径一多,跨团队对齐成本就上升,最后所有人都只信自己那一份数据。
本篇的做法是先把六个指标定死,再允许新增埋点,而且新增必须复用统一常量类。宁可少而准,不要多而乱。
9.2 评测通过率 100%,通常意味着评测集失效了
反直觉判断:回归全绿不是好消息,而是一条需要解释的信号。前面第 06 章已经看到,一个设计不当的判分器会让模型学会应付测试;当通过率长期钉在 100%,更可能的原因是评测集样本同构、判分过松,而不是系统真的没有退化。
可执行的做法是:给评测集维护一个「历史最难用例」子集,每次回归单独看这一子集的通过率。整体通过率可以不看,这个子集必须看。
9.3 什么时候不应该用本章这套能力
三类情况建议先不要上:一是系统还在单 Agent 单 Tool 的探索期,需求一周三变,此时埋点口径维护成本远高于收益;二是团队规模在一到两人,日志加人眼足以覆盖;三是项目本身没有灰度这个概念,每次都是全量发布且无回滚手段——这种情况下先解决发布方式,再谈观测。
判断标准很朴素:如果一次改动带来的后果,你无法在一天之内感知到,那观测可以先缓;如果你每天都要面对「改了不知道好不好」的问题,那这套东西越早越好。
9.4 Subagent 协作成功率是最难也最值得测的指标
它难,因为它同时耦合了委派决策、参数传递、超时预算和结果汇总四件事,任何一个环节出问题都会让它掉。它值得测,因为前三个单点指标(知识问答、Tool 参数、Tool 成功率)全绿的情况下,多 Agent 系统依然可能整体不work——而这种失败在只测最终答案的评测集里根本看不出来。
建议把它拆成三个可观测的中间量:委派次数、子任务返回是否被采纳、汇总是否丢失信息。这三个量都能从 Trace 上直接读出来,不需要额外埋点。
能看才敢放量
能量才敢改版
尺子先于功能
边界先于能力
10、系统落地
原来有什么。 前 28 式交出的是一套能力完整的单机数字人系统:Web 管理端与运行端、Spring Boot 服务、MySQL、LiveKit 实时会话、Realtime Bridge、AgentScope Java 推理链,以及 Tool、权限、状态、Harness、Memory、Skill、Plan、Sandbox、多 Agent 等一层层叠上去的能力。它能演示,能自测,但没有统一的观测口径,也没有可比较的评测基线。
本篇新增什么。 四件交付物:一份覆盖启动、配置、权限、超时、导出、回滚的生产检查表;一套可自动运行的回归评测,输出六个分层指标;一张最终架构图,明确观测与评测在链路中的位置;一次人为故障演练的记录,验证定位路径是否真的可用。
配置与部署层面的具体注意事项,建议对照官方 v2.0.3 的生产化文档逐条核验:https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/others/going-to-production.md 。
现在能做什么。 一次 prompt 调整之后,可以产出一份与上一版本逐项对比的评测报告;一次灰度异常,可以沿着 Trace 定位到是模型慢、Tool 慢,还是 Subagent 排队;一次回滚决策,有数据支撑而不是靠感觉。项目从「Demo 可演示」进入到「具备灰度条件」。
还缺什么。 缺的是长周期数据:评测集是随着失败样本积累而变强的,第一版一定很薄;也缺线上真实流量的采样策略,本篇只定义了采样点,没有给出生产环境的采样比例结论——这需要结合你自己的流量规模和成本约束确定。
下一步如何演进。 观测与评测稳定之后,系统规模化的下一个瓶颈会从「单机能不能跑」变成「多实例怎么协同」。这一层,AgentScope 生态里有控制面的思路可以参考:AgentScope Service 的相关介绍见 https://github.com/agentscope-ai/agentscope-java/blob/main/docs/v2/en/blogs/agentscope-service-release.md ,它讨论的注册、观察、会话操作与分布式协调,解决的正是多实例下的会话归属与状态可见性问题;生态中的 OpenJudge 则承担评测侧的定位,把判分从「自己写脚本」推进到「有统一评测框架」。
这里必须明确版本纪律:上述 Service 博文位于 main 分支,不属于本系列 v2.0.3 基线。它是否已随某个正式版本发布、API 是否稳定,请以官方 Release 页面为准:https://github.com/agentscope-ai/agentscope-java/releases 。更全的生态视角可参考 https://agentscope.io/ 。
开源参与点: 本章未发现明确上游问题。第 06 章定位到的两处根因都在教程侧的自定义 Middleware 与判分器中,按本系列规则不制造上游候选;如果后续在多实例观测中复现出可归因到框架的行为,再按「最小 Demo 复现 → 对比 v2.0.3 与 main → 检索官方 Issue/PR/Discussions」的顺序推进。
观测进主干
评测进流水线
检查表进评审
演练进日历
11、小结
Q1 → 没有刻度的系统不该发版:先用 Trace 把因果链连起来,再用评测集把版本差异量化
Q2 → 刻度优先刻在 Middleware:横切、无侵入、可组合;Harness 负责编排,评测集负责离线回归
Q3 → 刻度错了先回滚判断标准:判分口径写进代码并随评测集版本管理,而不是先回滚业务代码
状态 → 生产检查表、自动化回归评测、最终架构图、故障演练四项交付完成,项目具备灰度条件
这一式的价值不在于新增了多少能力,而在于把前面二十八式的能力变得可判断。从这一式开始,项目从「能跑」进入「敢放」。
入有埋点
出有指标
改有回归
放有灰度
12、作业
12.1 理解题:为什么观测量和评测输入必须来自同一份 Trace 结构?
参考答案:如果评测另起一套脚本,评测集就会逐渐与线上真实链路脱节,最终变成一份和系统无关的表演。共用同一份 Trace 结构之后,线上失败样本可以低成本转成回归用例,回归报告也能直接回答线上观测提出的问题,两条路形成闭环。
12.2 实战题:打开 AgentScope Java v2.0.3 的源码,说明 AgentTraceMiddleware 与 HarnessAgent 在调用链上的关系。
参考答案:请直接读固定版本的源码,而不是读本篇结论。入口是两个链接:https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AgentTraceMiddleware.java 与 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java 。重点看三件事:中间件在 Harness 的执行序列中于何时被触发、当前调用上下文如何传入、中间件能否拿到完整的会话与工具信息。最后建议顺手对比一次 main 分支,把差异记进自己的笔记——注意 v2.0.3 与 main 不能混为一谈。
12.3 排障题:Trace 里出现孤儿 span,你会按什么顺序排查?
参考答案:第一步排除采样,因为采样决定整条 trace 是否上报,不会只丢子节点;第二步确认父子关系是否建立,即生成子 span 时当前 Context 是否正确;第三步检查异步边界,尤其是自定义线程池与 CompletableFuture 之类不自动传播 Context 的场景;第四步用最小用例复现,再决定是否需要联系上游。顺序不能颠倒,否则容易像第 06 章的错误尝试那样,把时间花在错误的方向上。
12.4 架构判断题:本章的观测层是否应该直接参与灰度放行决策?
参考答案:不应该。观测层只负责采集、聚合与导出,放行决策属于发布流程。原因有三:观测后端自身可能故障,如果它参与决策,故障时会连带阻塞发布;指标口径可能变更,变更不应该等价于发布策略变更;语义层判分带有非确定性,不能用来做确定性的放行判断。正确做法是观测层输出数据,发布流程读取数据并做决策,两者之间通过报告这一层解耦。
别只背名词
去读源码链
跑一次最小例
写清判分口径
13、思考
回到开篇那个问题:昨天改了 systemPrompt,今天系统是不是变差了。
这一式给出的答案不是「变好了」或者「变差了」,而是「先定义什么叫变好」。没有这一定义,讨论就没有终点;有了这一定义,讨论反而会变简单,因为它把争论从感觉拉回到口径。
三个可复用的工程判断:一,先连起来,再多打点,因果链永远优先于数据量;二,口径必须写进代码,写在文档里的口径三个月后一定会分裂;三,观测层只输出判断依据,不做判断本身。
最后一句:会用框架,是能把它跑起来;能参与框架演进,是知道它在哪里会断、为什么断、断的时候该由谁负责。这两者之间的距离,恰好就是一把尺子的长度。
会用框架者
能改框架者
差在一把尺
量的是自己

浙公网安备 33010602011771号