AgentScope第13式·经脉中间件

AgentScope第13式·洋葱中间件

横切逻辑塞进 Tool,日志越写越多,链路却越来越断。

[读者] 用 AgentScope Java 做数字人的 Java 后端工程师
[痛点] TraceId、耗时、审计散落在每个 Tool 里,改一处漏三处
[现在读] 五阶段中间件正好能把横切能力收口到一处
[读完] 能给数字人接上一条完整、可闭合、可审计的调用链
[旧方案] 每个 Tool 里手写日志、计时、租户拼接
    |
    v
[新需求] 统一 TraceId、模型耗时、Tool 审计、答案过滤、租户注入
    |
    v
[冲突] 横切逻辑与业务逻辑长在一起,顺序、异常、上下文全靠口头约定
    |
    v
[后果] 链路缺口、耗时失真、审计遗漏,排障回到人肉比对

我是老李,最近带着一个小团队,把数字人 Demo 从“能跑”往“能查”推。项目不大:Spring Boot 管项目配置,MySQL 存数据,LiveKit 扛实时音视频,Realtime Bridge 做最薄的媒体适配,推理全部交给 AgentScope Java。

第12式结束时,数字人已经能按项目配置切换 systemPrompt、按租户切模型、按会话带上下文。能力是通的,但每加一个能力,就要回到每个 Tool 里补一段日志。

我们把日志粗分成两类:业务日志,谁写的谁负责;链路日志,谁都需要但没人负责。第二类日志在代码库里以复制粘贴的形式存在了十几处,而且没有一处能回答“这次请求的模型调用花了多久”——模型调用是框架内部发起的,业务代码根本没有位置插进去。

当横切逻辑和业务逻辑共用同一个函数体,谁来保证顺序不会因为一次重构而颠倒?

当模型调用藏在框架内部,业务 Tool 看不见它,耗时到底该记在哪里?

如果 TraceId 只在正常路径上传递,异常一来链路就断,这样的链路还叫链路吗?

01、故事

内测那天,会议室里坐着三个人:我一个,运营一个,还有一个刚入职的 Java 工程师。

系统是这样跑的:运营在管理端建项目,填名称、标题、主题色、背景图、开场白、结束语,再配一个 agentName、一个 model、一段 systemPrompt;用户在 /run/{projectId} 打开运行页说话;LiveKit 负责 WebRTC Room 与实时音频传输;Realtime Bridge 把媒体消息适配成 Agent 的输入;AgentScope Java 里的 Agent 负责推理;答案再一路回传成语音。闭环是通的。

不通的是“看得见”。

这次内测的任务是:让每一次用户说话,都能在日志里还原成一条从入口到最终答案的完整链路——哪个租户、哪次模型调用花了多久、调了哪个 Tool、Tool 收到什么、最终答案有没有被改过。

第12式结束时我们的做法很朴素:在 Tool 里加日志。查项目配置的 Tool 加一行 log.info,改配置的 Tool 再加一行。模型调用看不到,就干脆不记。内测第一天,运营问“刚才那句话为什么答错了”,我们三台机器 grep 了四个小时,最后靠时间戳猜出是哪一次请求。

那天晚上我盯着那堆日志想明白一件事:链路不是缺日志,是缺位置。

链路本无痕
横切自成环
一处收万象
先后定乾坤

02、问题

旧做法有五条,现在回头看,每一条都是同一个病的不同症状。

第一,Tool 内手写日志:TraceId、耗时、租户、审计字段,每个 Tool 各写各的。
第二,Controller 生成 TraceId,塞进参数,一路透传到 Service 再到 Tool。
第三,Service 里用 System.currentTimeMillis 打点算耗时。
第四,租户信息从请求头读到之后,在每个 Tool 里再读一次。
第五,答案过滤写在流式回调的收尾处,靠“约定这是最后一段”。

为什么不够?四个原因,都能在本机上验证。

一是位置不对。模型调用由框架内部发起,业务代码没有钩子可以插进去,模型耗时在 Tool 里永远量不到。

二是签名污染。为了让每个 Tool 都能写 TraceId,方法签名被迫带上上下文参数,Tool 的接口越来越像管道。

三是顺序靠约定。答案过滤必须在所有加工之后、输出之前,但只要有人在中间新增一步,顺序就可能颠倒,而没有任何编译期或运行期约束会提醒你。

四是异常先跑。工具抛异常时,函数提前返回,写在后面的日志一律不执行——链路正好在出问题的那一次断掉。

可验证的风险清单:

在任意一个 Tool 里抛一个 RuntimeException,TraceId 的结束日志立刻消失。

模型耗时字段长期为空,或者被误填成工具耗时。

换一个人写新的 Tool,审计字段必然缺两三个,因为没有统一模板。

一次重构把答案过滤挪到流式回调中间,问题不会报错,只会静默地过滤不干净。

本章的完成标准,我定成五条可验收的:

一次请求只有一个 TraceId,并且它同时出现在 onAgent、onReasoning、onActing、onModelCall、onSystemPrompt 五个阶段。

模型耗时与工具耗时分别统计,字段互不覆盖。

异常路径同样输出 trace.end,并带上错误类型。

业务 Tool 内不再出现任何 trace、耗时、租户、审计相关的代码。

代码落在分支 chapter/13-middleware,一次提交:feat(ch13): add middleware tracing audit and guards。

散落易失序
一改即失真
收口方成链
闭合才可信

03、原理

先给边界:AgentScope Java 里的 Middleware(中间件),是挂在 Agent 执行管线上的钩子,它不改变 Agent 的推理逻辑,只在阶段边界插入横切行为。本章只讲这五个钩子:onAgent、onReasoning、onActing、onModelCall、onSystemPrompt。官方对五阶段的说明与示例在 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/building-blocks/middleware.md ,源码入口在 agentscope-core 的 io/agentscope/core/middleware/MiddlewareBase.java。

第一件事:钩子的粒度不一样。

onAgent 是整次 Agent 执行,一次请求只进一次。

onReasoning 是一次推理步(ReAct 循环里的一轮),一次请求可能进多次。

onActing 是工具执行阶段,只有发生工具调用时才进。

onModelCall 是模型调用,每一轮需要问模型时都会进。

onSystemPrompt 是系统提示构建阶段,负责把最终送进模型的 system prompt 拼出来。

第二件事:洋葱模型。

进入顺序按注册顺序从外到内,返回顺序从内到外。外层钩子在 next 之前做的事,是所有内层的前置;在 next 之后做的事,是所有内层的后置。于是有四个推论:

要统计“整次请求”,放在最外层。

要统计“单次模型调用”,只能放 onModelCall。

要修改最终答案,必须放在比所有加工逻辑都外层的位置,因为只有那里拿得到最终结果。

要往 system prompt 里注入租户信息,必须放 onSystemPrompt,因为再往外,提示词就已经定型了。

第三件事:异常。

内层抛出的异常会沿调用栈向外冒泡。外层钩子想在异常时也留下记录,就必须用 try/finally,而不是把日志写在 next 之后。这和上一条是同一件事的两面:后置逻辑写在 next 之后,异常路径上就不会执行。

第四件事:上下文传递。

一次请求的 TraceId、租户、阶段耗时这些状态,要放在请求级上下文对象里随链传递。不要用 ThreadLocal——Agent 的执行可能是异步的、可能切线程、可能是流式的,ThreadLocal 在这些场景下会丢值或者串值。

反直觉判断:五个钩子不是“同一个东西的五个副本”,它们能看到的数据面完全不同。最外层看得到最终答案,但看不到模型请求;最内层看得到模型请求,却拿不到最终答案。所以“把日志统一写在最外层”看起来最省事,实际是拿不到要审计的东西——审计必须发生在看得见被审计对象的那一层。

最后一个必须说清的边界:本文按五阶段的名称与语义展开,但方法签名、返回类型、是否支持 Reactor 流式返回、默认实现是否为空方法,需要以 v2.0.3 的 MiddlewareBase.java 和对应 tag 的官方文档为准,本文未在当前环境逐行核对该源码。教程基线固定在 v2.0.3;写作时若官方 Releases 已出现更新的正式版,请以 https://github.com/agentscope-ai/agentscope-java/releases 为准,本文结论只对 v2.0.3 负责复现。

层层包一层
内外各所见
进出自有序
异常不乱纲

04、架构

[输入] 用户语音(LiveKit Room → Realtime Bridge)
    |
    v
[模块] AgentScope Java Agent + Middleware 链(六条中间件)
    |
    v
[数据/状态] RequestContext:traceId、tenantId、阶段耗时、审计事件
    |
    v
[处理] onAgent → onReasoning → onSystemPrompt → onModelCall → onActing
    |
    v
[输出] 最终答案 + 结构化链路日志 + 审计记录

注册顺序(外层到内层):

  1. TraceIdMiddleware(onAgent):开链、关链、生成或读取 TraceId。
  2. AnswerGuardMiddleware(onAgent):在最外层持有最终答案,统一过滤。
  3. ReasoningTraceMiddleware(onReasoning):推理步计数与步耗时。
  4. TenantPromptMiddleware(onSystemPrompt):把租户信息写进系统提示。
  5. ModelCostMiddleware(onModelCall):单次模型调用耗时。
  6. ToolAuditMiddleware(onActing):工具名、参数摘要、状态、耗时。

边界:中间件只处理“每个请求都要做、且与业务语义无关”的横切能力。凡是需要读业务字段做决策的逻辑,一律不入链。

收益:横切逻辑只写一次;顺序由注册顺序显式表达,而不是靠口头约定;模型耗时与工具耗时天然分离;异常路径也有收尾点。

代价:多一层间接,调用栈变深;排查一个问题要在中间件与业务代码之间来回跳;中间件顺序变成隐性契约,必须写进文档并纳入 code review。

适用条件:阶段边界稳定、横切能力与业务无关、链路需要被审计或统计。

不适用条件:只影响单个 Tool 的临时逻辑;阶段边界还在频繁变动;判断依赖业务状态(例如配额、风控)。

涉及 AgentScope 内部机制时,本章的定位依据是 v2.0.3 的 agentscope-core/src/main/java/io/agentscope/core/middleware/MiddlewareBase.java 以及同一 tag 的 middleware 文档;若两者描述不一致,以源码与测试为准,并在文中写明核验依据。

边界先画清
钩子各守位
上下文同行
日志自成链

05、实战一次

环境与版本:教程基线 AgentScope Java v2.0.3,依赖与 JDK 版本沿用第2式已经固定的环境,本章不引入任何新的第三方依赖。

依赖:只用到 agentscope-core 与 slf4j,pom 无需改动。

分支:从第12式所在分支继续创建本章分支。

# 先确认上一式分支名
git branch --list "chapter/12-*"
# 再切出本章分支
git checkout -b chapter/13-middleware <第12式分支>

配置:日志格式里补上 traceId,便于 grep。

<pattern>%d{HH:mm:ss.SSS} %-5level [%X{traceId:-no-trace}] %logger{20} - %msg%n</pattern>

核心实现:先把请求级上下文定义出来。

package com.example.digitalhuman.middleware;

import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

public class TraceContext {

    private final String traceId = UUID.randomUUID().toString().replace("-", "");
    private final AtomicInteger reasoningStep = new AtomicInteger();
    private final Map<String, Long> stageNanos = new ConcurrentHashMap<>();
    private final List<String> auditEvents = new CopyOnWriteArrayList<>();
    private volatile String tenantId = "unknown";

    public String traceId() {
        return traceId;
    }

    public String tenantId() {
        return tenantId;
    }

    public void tenantId(String tenantId) {
        this.tenantId = tenantId;
    }

    public int nextReasoningStep() {
        return reasoningStep.incrementAndGet();
    }

    public void record(String stage, long nanos) {
        stageNanos.merge(stage, nanos, Long::sum);
    }

    public Map<String, Long> stageNanos() {
        return stageNanos;
    }

    public void audit(String event) {
        auditEvents.add(event);
    }

    public List<String> auditEvents() {
        return auditEvents;
    }
}

再写六条中间件。下面代码块中出现的 log,均为各自文件里声明的 private static final Logger log = LoggerFactory.getLogger(...);Digest 与 GuardRules 是教程仓库同包下的工具类;AgentContext 与 MiddlewareChain 是按 v2.0.3 MiddlewareBase.java 描述的钩子形态书写的类型名,落地前请先打开该文件,把方法签名、返回类型与流式支持方式对齐后再编译。以下代码未在当前环境实测,行为为预期结果。

public class TraceIdMiddleware extends MiddlewareBase {

    @Override
    public void onAgent(AgentContext ctx, MiddlewareChain chain) {
        TraceContext trace = ctx.computeIfAbsent(TraceContext.class, TraceContext::new);
        trace.tenantId(ctx.header("X-Tenant-Id", "unknown"));
        long start = System.nanoTime();
        try {
            log.info("trace.start traceId={} tenantId={}", trace.traceId(), trace.tenantId());
            chain.proceed(ctx);
        } catch (RuntimeException e) {
            trace.audit("agent.error=" + e.getClass().getSimpleName());
            throw e;
        } finally {
            long nanos = System.nanoTime() - start;
            trace.record("agent.total", nanos);
            log.info("trace.end traceId={} costMs={} stages={} audit={}",
                    trace.traceId(), nanos / 1_000_000, trace.stageNanos(), trace.auditEvents());
        }
    }
}

public class AnswerGuardMiddleware extends MiddlewareBase {

    @Override
    public void onAgent(AgentContext ctx, MiddlewareChain chain) {
        chain.proceed(ctx);
        String answer = ctx.finalAnswer();
        if (answer == null || answer.isEmpty()) {
            return;
        }
        String filtered = GuardRules.filter(answer);
        if (!filtered.equals(answer)) {
            ctx.finalAnswer(filtered);
            ctx.get(TraceContext.class).audit("answer.filtered");
        }
    }
}

public class ReasoningTraceMiddleware extends MiddlewareBase {

    @Override
    public void onReasoning(AgentContext ctx, MiddlewareChain chain) {
        TraceContext trace = ctx.get(TraceContext.class);
        int step = trace.nextReasoningStep();
        long start = System.nanoTime();
        try {
            chain.proceed(ctx);
        } finally {
            long nanos = System.nanoTime() - start;
            trace.record("reasoning#" + step, nanos);
            log.info("reasoning.step traceId={} step={} costMs={}", trace.traceId(), step, nanos / 1_000_000);
        }
    }
}

public class TenantPromptMiddleware extends MiddlewareBase {

    private final TenantConfig tenantConfig;

    public TenantPromptMiddleware(TenantConfig tenantConfig) {
        this.tenantConfig = tenantConfig;
    }

    @Override
    public void onSystemPrompt(AgentContext ctx, MiddlewareChain chain) {
        TraceContext trace = ctx.get(TraceContext.class);
        String brand = tenantConfig.brandOf(trace.tenantId());
        ctx.appendSystemPrompt("\n[租户信息] 你正在为 " + brand + " 提供服务,回答中不要提及其他品牌。");
        chain.proceed(ctx);
    }
}

public class ModelCostMiddleware extends MiddlewareBase {

    @Override
    public void onModelCall(AgentContext ctx, MiddlewareChain chain) {
        long start = System.nanoTime();
        try {
            chain.proceed(ctx);
        } finally {
            long nanos = System.nanoTime() - start;
            TraceContext trace = ctx.get(TraceContext.class);
            trace.record("model.call", nanos);
            log.info("model.call traceId={} model={} costMs={}", trace.traceId(), ctx.modelName(), nanos / 1_000_000);
        }
    }
}

public class ToolAuditMiddleware extends MiddlewareBase {

    @Override
    public void onActing(AgentContext ctx, MiddlewareChain chain) {
        String tool = ctx.toolName();
        String argsDigest = Digest.sha256(ctx.toolArguments());
        long start = System.nanoTime();
        String status = "OK";
        try {
            chain.proceed(ctx);
        } catch (RuntimeException e) {
            status = "ERROR:" + e.getClass().getSimpleName();
            throw e;
        } finally {
            long nanos = System.nanoTime() - start;
            TraceContext trace = ctx.get(TraceContext.class);
            trace.record("tool." + tool, nanos);
            trace.audit("tool=" + tool + " args=" + argsDigest + " status=" + status + " costMs=" + nanos / 1_000_000);
        }
    }
}

注册顺序决定洋葱层次,这一行代码就是本章最重要的一行。builder 的注册方法名以 v2.0.3 为准。

ReActAgent agent = ReActAgent.builder()
        .name(project.getAgentName())
        .sysPrompt(project.getSystemPrompt())
        .model(modelFactory.of(tenant))
        .middleware(new TraceIdMiddleware())
        .middleware(new AnswerGuardMiddleware())
        .middleware(new ReasoningTraceMiddleware())
        .middleware(new TenantPromptMiddleware(tenantConfig))
        .middleware(new ModelCostMiddleware())
        .middleware(new ToolAuditMiddleware())
        .build();

启动:沿用第2式的启动方式,本地起 Spring Boot,同时确保 LiveKit 与 Bridge 已经可连。

请求:先用文本入口验证链路,再回到语音入口。

curl -X POST http://localhost:8080/api/run/1001/chat \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Id: t-1001" \
  -d '{"sessionId":"s-1","text":"帮我看看项目主题色是什么"}'

验证:下面是与上面实现配套的预期日志(示例输出,未在当前环境实测,字段以实际实现为准)。

INFO  [9f2c4a1b] dh.middleware - trace.start traceId=9f2c4a1b tenantId=t-1001
INFO  [9f2c4a1b] dh.middleware - model.call traceId=9f2c4a1b model=qwen-max costMs=812
INFO  [9f2c4a1b] dh.middleware - reasoning.step traceId=9f2c4a1b step=1 costMs=830
INFO  [9f2c4a1b] dh.middleware - model.call traceId=9f2c4a1b model=qwen-max costMs=121
INFO  [9f2c4a1b] dh.middleware - tool audit tool=queryProject args=3f9a1c status=OK costMs=6
INFO  [9f2c4a1b] dh.middleware - reasoning.step traceId=9f2c4a1b step=2 costMs=140
INFO  [9f2c4a1b] dh.middleware - trace.end traceId=9f2c4a1b costMs=1094 stages={agent.total=1094, model.call=933, reasoning#1=830, reasoning#2=140, tool.queryProject=6} audit=[tool=queryProject args=3f9a1c status=OK costMs=6]

对照验收清单逐条勾:一条 TraceId 贯穿五阶段;model.call 与 tool.queryProject 分开统计;异常用例下 trace.end 仍然出现;Tool 代码里搜不到 log 与 traceId;分支与提交信息符合约定。

五钩接一处
一链贯始终
耗时分开记
异常也要关

06、排查

本章的两条排查,一条关于位置,一条关于异常。

证据链一:模型耗时永远是 0,或者等于工具耗时

现象:内测日志里 modelCostMs 要么是空的,要么和 toolCostMs 一模一样。

怀疑一:模型没被调用,走了缓存或短路。
怀疑二:System.currentTimeMillis 精度不够,短调用被计成 0。
怀疑三:打点位置本身不对。

检查:在 Tool 里把计时前后各打一行时间戳,和框架侧日志中模型返回的时刻对照;同时确认模型调用发生在 Agent 内部,而不是 Tool 内部。

证据:工具执行时间只有几毫秒,而一次请求的端到端耗时是秒级;差值全部落在 Agent 内部,也就是模型调用上。说明“在 Tool 里测模型耗时”这个位置从一开始就不成立。

错误尝试: 我们一度在 onActing 里加了一个分支——“如果这一轮没有调用工具,就自己开始计时,等到下一次工具调用时再减掉”。这个改法错在两处:其一,它把 onActing 的语义(工具阶段)偷偷扩成了“整个推理阶段”;其二,没有工具调用的那一轮推理恰恰最容易漏统计,因为那个分支根本不会被触发,账面数字反而更漂亮。根因不是计时精度,是计时位置。

根因:模型调用的时间只有在 onModelCall 的前后才能量准。

修复:模型耗时整体搬到 onModelCall,工具耗时留在 onActing。

证据链二:异常一来链路就断

现象:正常请求日志里有 trace.start 和 trace.end;只要 Tool 抛异常,trace.end 就不见了。

怀疑一:中间件没注册上(不成立,因为正常路径生效了)。
怀疑二:异步日志没来得及刷盘。
怀疑三:异常被内层吞掉,链路其实还在跑。

检查:写一个必然抛 RuntimeException 的调试 Tool 触发一次;把 traceId 放进 MDC 观察;同时确认框架是否把异常重新抛出到最外层。

证据:日志确实停在 tool 之前,异常被抛回最外层,进程没挂,但 trace.end 没打。说明结束逻辑写在 next 之后,而不是 finally 里。

错误尝试: 我们把 trace.end 从处理函数末尾挪到 Controller 的 finally——错在把链路收尾和 HTTP 收尾混为一谈:LiveKit 侧的消息是异步进出的,Controller 早就返回了,收尾时机完全对不上。

根因:中间件缺 finally,后置逻辑在异常路径上不执行。

修复:所有中间件的后置逻辑统一放进 try/finally;审计事件结构化,不再字符串拼接。

上游贡献判断:这两条都是我们自己实现的位置问题,没有指向框架行为。若今后怀疑是框架语义问题(例如异常时钩子的调用约定),流程是:先在教程仓库做最小复现 → 对比 v2.0.3 与官方 main → 搜索官方 Issues/PR/Discussions → 再决定是否提 Issue。本章没有找到符合这个流程的候选。

现象先固化
证据再说话
根因不在表
位置定生死

07、优化

基于第06章的两条根因,V2 收敛成六条规则。

规则一:请求级状态全部收进 TraceContext(traceId、tenantId、阶段耗时、审计事件、推理步序号),随 middleware 链传递,不用 ThreadLocal。

规则二:每个钩子只做“只有它那一层看得见”的事。模型耗时只在 onModelCall,租户注入只在 onSystemPrompt,最终答案过滤只在最外层。

规则三:所有后置逻辑进 try/finally,异常路径与正常路径走同一套收尾代码。

规则四:注册顺序固定为 TraceId → AnswerGuard → ReasoningTrace → TenantPrompt → ModelCost → ToolAudit,并把这段顺序写进仓库文档。

规则五:审计输出结构化事件(tool、argsDigest、status、costMs),只落参数摘要,不落原始参数。

规则六:答案过滤只做一次,且必须发生在最外层。

把规则三落成代码,收尾逻辑就只有一种写法。

private static void withTiming(TraceContext trace, String stage, Runnable body) {
    long start = System.nanoTime();
    try {
        body.run();
    } finally {
        trace.record(stage, System.nanoTime() - start);
    }
}

把规则六落成代码,过滤只在一处发生。

public final class GuardRules {

    private static final Pattern PHONE = Pattern.compile("1[3-9]\\d{9}");
    private static final Pattern INTERNAL_LINK = Pattern.compile("https?://(\\w+\\.)*internal\\.[a-z]+");

    private GuardRules() {
    }

    public static String filter(String answer) {
        String masked = PHONE.matcher(answer).replaceAll("***");
        return INTERNAL_LINK.matcher(masked).replaceAll("[内部链接已隐藏]");
    }
}

新行为与验证方法:准备三类用例——正常请求、工具抛错请求、无工具请求(纯模型回答)。三类都必须满足同一条:日志里有且只有一条 trace.start 与一条 trace.end,且 stages 里同时出现 model.call 与(当次实际发生的)tool.* 键。第三类用例是专门用来抓“工具里计时”这个老毛病的:如果耗时不出现,说明你又把统计写回了工具里。

位置即语义
收尾放最外
一事一钩子
验证看异常

08、演进

[同一输入] 用户一句语音 → 数字人一次回答
    |
    +--[V1] 横切逻辑写在 Tool 内 / 日志零散、模型耗时缺失、异常断链
    |
    +--[V2] 五阶段中间件收口 / 一条 traceId 贯穿、异常也闭合
    |
[Trade-off]
得到:统一链路、可审计、可回归;失去:多一层间接、调试要跨文件;适用边界:只放与业务无关的横切能力
维度 V1:Tool 内手写 V2:五阶段中间件
正确性 模型耗时量不到 模型耗时与工具耗时分离
稳定性 异常路径断链 try/finally 闭合,异常也收尾
复杂度 单点简单,全局失控 单点多一层,全局一致
成本 每新增一个 Tool 就重复写一遍 一次注册,长期受益
适用范围 单个 Tool 的临时验证 多能力、多租户、需要审计
遗留问题 无统一模板,字段随人变 中间件顺序成为隐性契约,必须文档化

值得注意的是,V2 的复杂度并没有减少,只是从“十几个地方各一点点”变成了“一个地方多一点”。这对单个开发者是负担,对频繁迭代的团队是收益——因为变更成本从 N 处收敛到了 1 处,回归成本也跟着收敛。

旧写散如沙
新束成一链
得失各有价
边界是前提

09、洞见

9.1 顺序就是契约

中间件的注册顺序不是实现细节,而是对外契约的一部分。把 AnswerGuard 挪进内层,答案过滤就会漏掉内层加工产生的内容;把 TraceId 挪进内层,外层就可能出现没有 traceId 的日志。这类改动应该和改 API 一样,进 code review、进变更记录。

反直觉判断:团队通常把“中间件顺序”当成可以随时调整的排列组合,但对链路能力来说,它可以随时调整的排列组合,恰恰是不该混进业务变更里的那种改动。

9.2 只有它能做的事,才放进它的钩子

一个简单的判据:这件事除了这一层,还有谁能做?如果没有别的地方能做,就放这里;如果很多地方都能做,说明它不属于中间件。模型耗时只有 onModelCall 能做得准,租户注入只有 onSystemPrompt 能做得对,最终答案过滤只有最外层能做得全——这三件事都是“只有它能做”,因此位置是唯一的。

9.3 异常路径才是链路的验收标准

反直觉判断:链路的正确性不应该用成功请求来验收。成功请求在 V1 里也是通的,所以它能证明的东西很少;真正的验收标准是异常请求同样有 trace.end。把异常用例写进验收清单,是这一章最便宜也最容易被省掉的一步。

9.4 什么时候不该用中间件

三种情况。其一,这条横切逻辑只影响一个 Tool,没有第二处会用到,那它就还是业务代码。其二,阶段边界还没稳定,团队还在改推理流程,此时把逻辑钉在钩子上,会跟着边界一起晃动。其三,判断需要业务语义,例如“这个租户今天还能不能聊”“这段内容算不算违规”,那属于业务规则,应该放在进入 Agent 之前或业务层,而不是让模型调用阶段承担业务决策。

顺序即契约
内外各有责
直觉常出错
边界即自由

10、系统落地

原来有什么:第12式的数字人已经能按项目配置切换提示词、按租户切模型、按会话带上下文,但链路能力为零,排障靠 grep 和时间戳。

本篇新增什么:六条中间件、一个请求级上下文、一套结构化审计事件、一条答案守卫。业务 Tool 里不再出现 trace、耗时、租户、审计相关代码。

现在能做什么:一次请求一条 TraceId;模型耗时与工具耗时分开统计;异常路径也输出闭合记录;租户信息在 systemPrompt 阶段注入;最终答案在输出前统一过滤。

还缺什么:链路日志还只落在本地文件,没有统一的观测面;审计事件还没有留存与查询策略;答案过滤还没有回归用例,无法判断是否误伤;多实例部署时 TraceId 还缺跨服务传播约定。

下一步如何演进:把链路日志接到统一观测;把审计事件变成可查询记录;给答案过滤建一组固定用例做回归;在进入分布式部署前,先定好 TraceId 的跨服务传播格式。

开源参与点:本章未发现明确上游问题。

原有能跑通
今添可追溯
缺的是观测
下一步接上

11、小结

Q1 → 顺序由谁保证:由注册顺序与洋葱模型共同保证,收口之后不再依赖口头约定
Q2 → 模型耗时记在哪:onModelCall,只有它在模型调用前后都经过
Q3 → 异常链路:后置逻辑统一 try/finally,异常路径同样要有 trace.end
状态 → chapter/13-middleware 可交付:一次请求的完整调用链 + 模型/工具耗时分离统计

本章的交付物不是“多了一个日志框架”,而是给横切逻辑找到了唯一该待的位置。位置定下来之后,后续加审计字段、加埋点、加护栏,都只需要改一个文件,而不是改十几个 Tool。

一问顺序谁定
二问耗时何记
三问异常怎收
一链贯通收工

12、作业

12.1 理解题:五阶段钩子的进入顺序与返回顺序是什么关系?为什么叫洋葱模型?

参考答案:进入按注册顺序由外到内,返回由内到外;同一份请求上下文在“进入”和“返回”两个时刻看到的状态不同,所以计时、过滤、收尾这类逻辑天然适合写在返回路径上。具体嵌套关系(例如 onSystemPrompt 相对 onModelCall 的位置)请打开 v2.0.3 的 io/agentscope/core/middleware/MiddlewareBase.java 与同 tag 的 middleware 文档逐条核对,不要只背本文。

12.2 实战题:把 TraceId 中间件改成支持流式输出——逐 token 返回时,仍然只在结束时打印一次 trace.end。

参考答案:把收尾逻辑放在流的终止信号上(onComplete/onError)或 finally 中,而不是每个 token 的回调里;若需跨线程,把请求上下文显式传给订阅者,别依赖 ThreadLocal;同时在 onError 分支补一次 trace.end,保证错误流也闭合。具体用哪个终止回调,要以 v2.0.3 源码里中间件钩子的返回类型为准。

12.3 排障题:日志里出现了两次 trace.start,只有一次 trace.end,先查什么?

参考答案:先数注册次数——是否存在同一中间件被注册两遍(例如 builder 里一次、全局配置里一次);再查是否存在嵌套执行(Subagent 或子 Agent 复用了同一上下文对象),此时子链路的开始应该带独立的 span 标识,而不是复用外层 traceId 的开闭。定位手段是把 traceId 与中间件注册顺序一起打进日志。

12.4 架构判断题:要不要把“租户配额校验”放进中间件?

参考答案:不应该放进内层钩子。配额校验依赖业务状态(用量、套餐、时间窗),属于业务准入;放进 onModelCall 会让“调用模型”这件事承担业务决策。更合适的做法是在进入 Agent 之前或在最外层 onAgent 做准入,中间件只保留与业务无关的横切能力。

理解看源码
实战改流式
排障数日志
判断问边界

13、思考

回到开篇那个冲突:横切逻辑和业务逻辑长在一起,谁也说不清顺序、异常和上下文该谁负责。中间件给出的不是一个新的日志工具,而是一个位置——一个“每个请求都要做、且和业务无关”的逻辑唯一该待的位置。

可复用的判断只有两句:凡是每个请求都要做、和业务无关、需要看见阶段边界的,收口到中间件;凡是需要读业务字段做决策的,留在业务层。判断不清的时候,先问一句:这件事,除了这一层,还有谁能做?

这也是“会用框架”和“能参与框架演进”的差别:前者照着钩子名把代码塞进去,能跑就行;后者会先确认这些钩子在异常、并发、流式三种情况下的语义,再决定塞什么、塞在哪一层。

横切归一处
先后即契约
异常也闭合
方寸见真章

posted @ 2026-10-11 11:16  李福春  阅读(5)  评论(0)    收藏  举报