AgentScope Java v2 第7课,中间件洋葱模型 摘要Agent 每条回答都带上了「MW」前缀——没改框架、没换模型、没动提示词模板只加了一个类。这就是中间件在执行流外面套你自己的代码。本文按五个钩子逐个讲透插在哪onAgent / onReasoning / onActing / onModelCall / onSystemPrompt用一行源码讲清 order 排序套娃用对照实验划出边界——能改输入、能加工事件流、碰不到历史。附文档漂移第 8 处的版本考古与字节码、源码、实测三步方法论。共约 7251 字 · 阅读约 18 分钟1、开场每条回答都带 MW——模型被夺舍了本以为给 agent 动这种手术得改框架。跑完那条订舱指令agent 的回答带上了「MW」前缀——不是模型自己学会的是套在外面的东西干的而做到这一点只加了一个类builder 上注册一行。先摆你我的默认动作那也是我原来的动作。加个审计日志业务代码里 try-finally 一包进方法起个计时出方法落一条日志有几把工具就得包几遍工具和工具之间发生的事一概失明。要改模型行为改提示词模板把口径焊死在 system prompt 里换个租户再焊一份。这两招覆盖了不少场景单工具内部的局部逻辑本来就该写在工具里我认。失效临界点在工具外面。有一类逻辑不属于任何一行业务工具整单审计、限流计费、给特定租户换工具菜单、给所有回答盖章。它们属于 agent 的每一相——每次运行、每轮思考、每批工具调用、每次模型调用——散进业务里写等于每个工具改一遍还漏一半写进模板等于焊死。工具和工具之间、轮次和轮次之间那些没有归属的时刻恰恰是审计和管控最该站的地方。面板先看疗效。读图最终回答从「MW」起头而它前一轮的我先核对一下船期和货物信息。没出现——两个现象这课都会拆先记着。这张图是 Playwright 面板实操截的服务日志全程在录主线日志都出自 10:59 那场素材运行对得上号第 4 节的双层洋葱实验另有场次到时注明。做到 MW 这一个效果我只写了 implements 加一行注册框架代码零改动模型没换提示词模板没动。揭底给人话版。有同事看完 demo 直说「这一课能不能说人话」——能agent 干活的流程是框架的代码你改不了中间件就是在流程外面套你自己的代码之前干点啥、之后干点啥原流程一行不动。记日志的、改提示词的、滤文本的都是这一句话的变体这课全跑过。动手前先跟 AOP 对齐坐标第 2 节正文讲三件事——插在哪第 3 节五个钩子、插几层谁包谁第 4 节order、能干什么不能干什么第 5 节边界第 6 节收文档的坑。读之前把你可能要问的六个问题摆出来后文逐个回收它像 AOP 吗五个钩子各插在哪order 的比大小发生在哪注入两个中间件流程执行两次吗中间件能改历史吗文档可信吗每一步有日志和截图作证。2、洋葱是什么和 AOP 是一家人同事把 demo 跑起来看了眼注册方式就问「我怎么感觉有点像 aop 呢」——是一家人。Spring AOP 的Around长这样Around(execution(* com.example.service..*(..)))publicObjectaround(ProceedingJoinPointpjp)throwsThrowable{// before进方法前Objectresultpjp.proceed();// 放行// after方法返回后returnresult;}AgentScope 的中间件把同样三段换个姿势摆给你publicFluxAgentEventonAgent(Agentagent,RuntimeContextctx,AgentInputinput,FunctionAgentInput,FluxAgentEventnext){// pre流程进入前returnnext.apply(input);// 放行——next 就是你的 proceed()// post流走完后补一笔doFinally 里}对照着读proceed()就是next.apply()before 段写在 apply 之前after 段写在流终结之后。短路也一样成立——apply 之前直接返回你自己造的流、不调 next.apply流程就不往下走了官方 StopOnAllDeniedMiddleware 的全拒即停本质上就是一次受控短路。谱系也不新鲜Servlet Filter 链、Netty Pipeline、Spring AI 的 Advisor早期名字就叫CallAroundAdvisor——Java 世界给调用链套逻辑几十年大都是这一套思路。但别把 AOP 经验整个平移有两条真差异。其一切点从自由表达变成封闭清单AOP 写切点表达式想切哪切哪AgentScope 预定义五个钩子你覆写哪个钩子哪个就是你的切点表达式。这有好有坏不用学表达式语法、五根线一眼看全代价是框架不加钩子你就切不进去——封闭清单的封闭就是这个意思。其二更细的刀不在这层单个工具的参数级拦截归第 6 课讲过的 checkPermissions两根管各管各的第 5 节回来对边界。隔壁框架也看了眼LC4j 的 ChatModelListener 是只读通知唯一能写的是个 attributes map。论下刀的自由度中间件这套宽得多——能改输入、能加工输出、能短路Listener 只能站在旁边记笔记。再清一个误会。有同学指着代码问「OnionLogMiddleware 这个是什么」——我们自己写的类不是框架的。关系三层框架在 jar 里给MiddlewareBase接口五个钩子全是 default 空实现我们 implements 它只覆写关心的那几个builder 注册一行框架来调。跟第 5 课的Tool、第 6 课的ToolBase同一套路——框架定接口你写类框架来调——区别只在切的位置那两回在工具上这回在流程的相上。最小骨架长这样真要抄八行起步publicclassMyMiddlewareimplementsMiddlewareBase{OverridepublicMonoStringonSystemPrompt(Agentagent,RuntimeContextctx,Stringprompt){returnMono.just(prompt 回答口径正式商务。);// 加工后递下去}}// 注册就这一行builder.middleware(newMyMiddleware());3、五个钩子插在哪先交代实验环境后文所有日志同源一个最小示例工程DeepSeek 真模型7 个货代工具查船期、查舱单、查货详、订舱、取消、退款、报时面板发指令、审批卡点批准。五个演示中间件各由 application.yaml 的开关控制——mw-onion、mw-stamp、mw-final-filter、mw-timing、mw-acting-audit改完重启生效。接口给五个钩子先上总表定坐标系。读表先读包住什么列洋葱的每层皮包一个对象包什么决定你看见什么、拦得下什么。选钩子的手筋就一句先问你要看见哪一相——整单、单轮、单批工具、单次调用、提示词拼装五选一错位了多半白看。频次列也有信息量——有同学问「MiddlewareBase 的 onXXX 是 5 个固定的钩子回调」答案先给是封闭清单但固定不等于各调一次onAgent 一次运行进一次onReasoning 每轮一次onActing 每批一次onModelCall 每次调用一次。下面五节共用一个讲法包什么、代码、实测读数、接线原理、货代场景。可以跳读每节自足。3.1 onAgent整单审计位包的是整个运行。你调一次 call 或 streamEvents 算一次运行从指令进来到流终结进出各触发一次是最外层的书挡。resume 也算新运行重新触发一遍。代码就是在 next.apply() 上挂个 doFinallypublicFluxAgentEventonAgent(Agentagent,RuntimeContextctx,AgentInputinput,FunctionAgentInput,FluxAgentEventnext){longt0System.currentTimeMillis();returnnext.apply(input).doFinally(s-log.info(会话[{}]运行结束总耗时 {} ms,ctx.getSessionId(),System.currentTimeMillis()-t0));}实测日志10:59 的素材运行指令是「帮我在 MSC AURORA 的 V-2609A 航次订 C-001 这票货」摘录出自双层日志观察器 OnionLogMiddleware 的打点——pre 一行、post 一行带耗时与事件数与上面这段最小写法机制相同、打点更全[MW-OUTER|onAgent] pre (10:59:52.650) ……中间几十行三轮推理、三批工具、审批请求…… [MW-OUTER|onAgent] post (5074 ms, 357 events, onComplete) (10:59:57.725)第 1 行是进第 3 行是出这一段运行 5.1 秒、357 个事件、正常收尾。别看写法简单括号里那个 s 是信号类型onComplete 还是 cancel 一目了然——排查怎么没跑完就断了时第一个看它。你批准审批卡之后的 resume 是另一条运行日志里有它自己的一对 pre/post174 个事件、1531 ms各记各的账。顺带埋一个钩子这一场订舱号是 BK-20261028-1换个新会话再订同一票它会跳成 -2——为什么结尾收。原理接线点就是 ReActAgent 主入口AgentInput 里的 msgs 是本次输入的消息表。官方的 OtelTracingMiddleware 在这层打 invoke_agent span——你和可观测性组件站同一个位置。时间戳还能再榨一点pre 在 52.650post 在 57.725而批准后 create_booking 的真执行在 11:01:25——第一段运行走到审批就停了5.1 秒里不含你点批准之后的活。货代场景这就是整单审计的入口第一段运行 5.1 秒、357 个事件走到审批就停你批准后的 resume 续跑 1.5 秒、174 个事件把 BK-20261028-1 订下来。合规问起来谁、几点、发起了一次什么会话全在案。3.2 onReasoning动态菜单位与滤文本位一轮思考进来先过你这关。输入 record 里三样messages到目前的对话、tools这轮允许用的菜单、options。菜单可换输出的事件流可加工每轮进出各一次。改的方式只有一种造份新 record——record 不可变框架不给你 setters这正是改输入不改历史的物理底子「一样不能干」一节还要回来用它。换菜单的代码appendVipTool 是示意辅助未入实测——record 不可变换菜得造份新的publicFluxAgentEventonReasoning(Agentagent,RuntimeContextctx,ReasoningInputinput,FunctionReasoningInput,FluxAgentEventnext){if(vip-customer.equals(ctx.getUserId())){ListToolSchematoolsappendVipTool(input.tools());// 大客户才给改舱工具returnnext.apply(newReasoningInput(input.messages(),tools,input.options()));}returnnext.apply(input);}实测读数素材运行三轮推理日志三对 pre/post事件数 118 / 165 / 60耗时 2243 / 1730 / 906 ms越往后越轻更像是该查的查完了任务在收敛tools7 没动过动的是事不是菜单。面板效果开场那张图亮过最终回答直接从「MW 订舱已完成 ✅」开始我先核对一下船期和货物信息。这句中间轮话没影了。它不是模型没说——模型原始流的 DEBUG chunk 里原话带章可查——是内置的 FinalAnswerFilterMiddleware 在这层把中间轮文本从给前端的事件流里滤掉了。滤之前流里有什么、滤完模型还记不记得第 5 节给对照账。原理接线点是 reasoning lambda。嵌套结构上 onSystemPrompt 和 onModelCall 都在它作用域内——这层是中层皮外面包着 onAgent里面裹着模型调用。messages 每轮在涨涨多少「计时、计费与热切换位」一节的 msgs 读数给答案。滤文本还有个反面要掂量把工具调用过程全滤掉客户看不见 AI 动了什么手出了争议说不清——滤什么、留什么先问审计要什么。货代两条按租户裁菜单大客户才见改舱工具普通客户菜单里压根没这项把自言自语滤掉给客户一份干净回答审计也在这层做了。3.3 onActing工具审计位参数全在案包的是每批工具执行。单位是批不是个——同一轮模型并行的几个调用算一批进出各一次。ActingInput.toolCalls 是模型的点菜意图ToolUseBlock原样递进来工具名、完整参数全有。审计中间件全文就这么几行publicFluxAgentEventonActing(Agentagent,RuntimeContextctx,ActingInputinput,FunctionActingInput,FluxAgentEventnext){input.toolCalls().forEach(tc-log.info([MW-AUDIT|onActing] tool{} args{},tc.getName(),tc.getInput()));returnnext.apply(input);}三批全录[MW-AUDIT|onActing] toolquery_vessel_schedule args{vesselNameMSC AURORA} [MW-AUDIT|onActing] toolquery_voyage_cargo args{voyageIdV-2609A} ← 同轮并行同一批 [MW-AUDIT|onActing] toolquery_cargo_detail args{itemIdC-001} [MW-AUDIT|onActing] toolcreate_booking args{itemIdC-001, voyageIdV-2609A, remark…}逐行读第 1、2 行同一批模型一轮并行查了船期和航次货物第 3 行单独一批第 4 行 create_bookingitemId、voyageId、remark 完整在案。批与批的节奏也读得出来批 1 落在 54.875批 2 在 56.817批 3 的 create_booking 在 57.687模型每想一轮、动一批间隔一到两秒全在日志里。面板侧事件流的 TOOL_CALL_* 和审批卡上展示的参数同源都出自这批 ToolUseBlock读图卡上工具: create_booking和可编辑参数框里的三个键与上面第 4 行审计日志的 args 一一对应——模型点的菜、中间件记的账、你批的准三处是同一份数据。原理里有一条要单独说接线点除了 acting lambda还有个 emitAllToolsDeniedThroughMiddleware。第 6 课埋的全拒即停伏笔在这握手——官方的 StopOnAllDeniedMiddleware 就在这层听 AllToolsDeniedEvent然后发 RequestStopEvent 让整单停机。上节课它只有现象没名字这课对上了号那根管子的另一头就是中间件。边界在这说一半它只管 agent 运行时内的工具执行外部直接 Toolkit#callTool 不经过单工具参数级拦截归 checkPermissions不归中间件——「管道与洋葱的分界」把这条说完。货代场景合规要的是AI 在 10:59:54 对客户的订舱发起了 create_booking参数完整在案这层给的就是这个再挂个全拒即停无人值守也有安全阀。3.4 onModelCall计时、计费与热切换位这一层站在模型门口一次调用进一次出。这个 record 给得最肥ModelCallInput.model 里连模型实例都给你。计时写法publicFluxAgentEventonModelCall(Agentagent,RuntimeContextctx,ModelCallInputinput,FunctionModelCallInput,FluxAgentEventnext){longt0System.nanoTime();StringmodelString.valueOf(input.model().getModelName());returnnext.apply(input).doFinally(s-log.info([MW-TIMING|onModelCall] model{}, msgs{}, tools{} - {} ms ({}),model,input.messages().size(),input.tools().size(),(System.nanoTime()-t0)/1_000_000,s));}四次调用全录[MW-TIMING|onModelCall] modeldeepseek-flash, msgs2, tools7 - 2242 ms ← 轮1 [MW-TIMING|onModelCall] modeldeepseek-flash, msgs5, tools7 - 1729 ms ← 轮2 [MW-TIMING|onModelCall] modeldeepseek-flash, msgs7, tools7 - 906 ms ← 轮3 [MW-TIMING|onModelCall] modeldeepseek-flash, msgs9, tools7 - 1519 ms ← resume 最终轮重点读 msgs 列轮 1 是 2系统提示词加用户那句话轮 2 变 5多了上一轮助手回复和两条工具结果轮 3 到 7resume 那轮 9。2→5→7→9msgs 一轮轮涨这层全程看得见——模型每轮实际看见多少东西这里最清楚计费按 msgs 算就有据。tools7 恒定说明这趟菜单没被动过。耗时列跟 3.2 对得上每轮恰好一次模型调用onReasoning 的耗时基本就是模型调用的耗时。还有个反直觉的读数轮 1 两条消息跑了 2242 ms轮 3 七条消息只跑 906 ms耗时跟 msgs 不成正比多半是该查的早查完了后面轮次在收敛。原理换模型就在这拐弯——构造新的 ModelCallInput把 model 换成备用实例再 next.apply官方 ModelFallbackMiddleware 是这套骨架简单场景直接用 builder 的 fallbackModel 和 maxRetries不用自己写。频次这里补全maxIters 打满后的 summary 收尾也是一次模型调用一样过这层——接线点包含 summarizing()一次都不漏。嵌套图上它挂在 onReasoning 内部两层各报各的事件数可以不同实测轮 1onReasoning 报 118 个事件onModelCall 报 131 个——模型原始 chunk 没滤、事件层滤过。货代落点分级计费每次调用记账船期快查走便宜模型、单证校验走强模型。模型名硬编码在业务代码里、切个模型要改代码重发的日子到这层为止。3.5 onSystemPrompt人设注入位它是管道不是洋葱这一层最特别进一个 String出一个 String。人设、口径、当前操作员都从这进。频次是每运行一次不是每轮——算好的结果复用给这一运行的全部轮次。盖章中间件的核心逻辑完整类另含 MARKER 常量定义// 日志里那行 appended marker 出自这里的 log.infopublicMonoStringonSystemPrompt(Agentagent,RuntimeContextctx,Stringprompt){log.info([MW-STAMP|onSystemPrompt] appended marker ({} - {} chars),prompt.length(),prompt.length()MARKER.length());returnMono.just(promptMARKER);// MARKER 「每条回答以 MW 开头」那句章}实测日志一行[MW-STAMP|onSystemPrompt] appended marker (114 - 171 chars)114 到 171加的 57 个字符就是那句章。运行 1 内三轮推理只有这一条resume 新运行另有自己的一条——恰好坐实每运行一次。文档的嵌套图把它画在 loop 里我照图以为每轮都跑实测打脸盖章时间戳 52.823首轮推理 pre 是 52.832章盖在推理开始之前一次管全场。面板效果开场图已给每条回答以「MW」开头。日志旁证是四轮请求的 system content DEBUG 行带的都是盖章版。原理要说清一个身份它是管道不是洋葱。判据一句话——加工对象有没有流。前四个钩子包的是 Flux流里有中间事件能包、能插手、能改道这个钩子包的是 String一页文稿只能接住、改完、递下去上一环输出就是下一环输入。为什么这里不做洋葱提示词是单值没有中间事件可插手硬套成洋葱也只是把接住改完递下去换个写法框架没骗你。实测管道序order 是每个中间件申报的序号第 4 节专讲这里先记现象order 30 的先 passorder 10 的再 passorder 1 的最后盖章降序传递跟洋葱包裹方向一致。边界先记一笔它改的是过程请求拼装不是状态历史——章生效于每次请求不入史关开关立即复原第 3 课讲过 sysPrompt 不入史。货代场景注入当前操作员张三回答口径正式商务动态口径金额回答必须附大写当前时间注入——官方 DynamicContextMiddleware 干的就是最后这件。4、order插几层、谁包谁注入多个中间件的那天同事连发两问「builder.middlewares new 进去 2 个哪个先执行……重复执行 2 次」第一问的答案藏在一行源码里第二问是误会。4.1 套娃是怎么长出来的源码一行注册时什么都没发生builder 只存清单。排序发生在 build 时就一行字节码直译的等价 Java怎么钉死的见第 6 节middlewares.sort(Comparator.comparingInt(MiddlewareBase::order).reversed());位置在 ReActAgent.java:5327。我原以为 order 的比大小发生在运行期调用时有个比较器在跑——错了build 时排一次序就完事运行期零比较这课第二组预测错记录在案。同事盯着这行问「也没有获取 order 比大小啊堆栈出入顺序的原因吗」——比了但只在 build 时比这一次。调用时 MiddlewareChain 从排好的清单尾部往头套娃order 最小的先包成最内层最大的最后包成最外层。那场双层洋葱时序实验同日早些的另一场运行351 事件、4696 ms 那次的注册清单是 OUTER(30)、INNER(10)外加管道位的 STAMP(1)未覆写 order取默认值STAMP 是管道不参与洋葱套娃的就是前两层没有栈数据结构。你可能以为先包后解得有个栈顶着其实先入后出就是嵌套函数加普通方法调用栈的自然结果——OUTER 调 INNERINNER 调真流程JVM 调用栈替你记着回来的路。order 取值也没有规定动作30、10、1 只是我们挑的刻度只有相对大小有意义绝对值谁也不看。实测里 post 是 OUTER 先打的跟直觉相反。为什么会反过来我们挂钩子用的是 doFinally它的回调不在事件路上——终端信号先传给下游传完才轮到它跑。完成信号从真流程一层层往外传最外层最后收到信号、却最先轮到跑钩子post 就成了外层先打。想看教科书式的内层先出把 doFinally 换成 doOnComplete先拦后传就行——同一个洋葱两种钩子选型两种可见顺序。这是钩子的学问不是洋葱反了。4.2 执行 2 次是个误会把杠摆上桌注入两个中间件那流程不就执行两次了我当初的预测就是这个错了一半。准确答案真流程只跑一份。两个实例的钩子代码各跑一遍所以日志两份但递给它们的 next 不是同一个——OUTER 的 next 指向 INNERINNER 的 next 指向真流程。每层收到的是往下传的入口不是流程本体。把一次调用在脑内走一遍你订阅的流是 OUTER 返回的OUTER 记完 pre 把 INNER 的流递上来INNER 记完 pre 把真流程的流递上来事件从真流程冒头先过 INNER 的手、再过 OUTER 的手——一层就多一道手仅此而已。铁证在日志里。那场双层洋葱时序实验另一场运行场次在第 4 节开头已注明里两层都挂了 onAgentpost 出来两行每行都报 351 个事件耗时同为 4696 ms。真跑两遍第二遍该报 702同一个数出现两次是同一股事件流从两层眼前过两层各数了一遍。耗时也能这么读两行同为 4696 ms 不是巧合两层看见的是同一条流的同一次终结表各自记了一遍。4.3 为什么日志里有两次运行另一个容易撞上的现象onAgent 的 pre 打了两轮。别急着怪中间件——两根轴别搅一起运行次数由 HITL 决定ASK 暂停、人批准、resume 接跑每次 resume 是条新 HTTP 请求算一次新运行日志层数由中间件个数决定。摆成矩阵无 HITL 加两个中间件一次运行两层日志有 HITL 加两个中间件两次运行各两层。分清两根轴有实操收益查日志先按运行分段再数每段里的层数——顺序反了两段运行的故事会被你讲串。素材运行就是两根轴同时现身的例子第一次运行三轮推理后停在审批批准后 resume 真正执行了 create_booking模拟登记出 BK-20261028-1。顺带答掉「注入 2 个和注入 1 个目的是什么呢」——1 个是加一种切面能力N 个是切面组合审计放最外要看得见限流拦截本身层级即视野限流居中注提示词、滤文本靠内独立开关、独立复用order 本身就是协作语义。教学里俩同类中间件是显微镜不是生产姿势。5、边界能改什么、不能改什么毕业检查时同学对中间件能干什么只答出「能改提示词」——三分之一。完整账本三笔能干、一笔不能干每笔按同一格式对保证什么、不保证什么、实测在哪。5.1 三样能干第一样改输入 record。record 不可变但可以造份新的递下去落点三处各管各的进料onModelCall 换模型onReasoning 换菜单onSystemPrompt 改提示词——3.2、3.4、3.5 的代码都是现成例子。保证的是下一次模型调用就用新输入VIP 菜单和 MW 章都即时生效不保证的是你手里这份改动会成为会话的既成事实它只作用于这一次进料。第二样加工事件流。流里的文本事件可滤可改FinalAnswerFilterMiddleware 滤中间轮文本是官方样本。保证的是前端干净模型原始流里有「我先核对一下船期和货物信息。」DEBUG chunk 原话带章面板上没有不保证的是这句没发生过——它只是没到前端别处都在。第三样按 order 组合叠加。审计最外、限流居中、注提示词和滤文本靠内独立开关、独立复用。保证的是层与层互不知晓也能协作order 即协作语义不保证的是层数没有成本——每层都过一遍事件流层厚了不好读也不省事。5.2 一样不能干历史先看一样它看得见的东西。素材运行里模型每轮收到的消息数读表2→5→7→9历史在中间件眼皮底下一轮轮长肉——看得见摸不着。record 是递到你手里的一份进料单会话状态里存的那份历史五个钩子没有一个给你写入口。对照着看5.1 的只作用于这一次进料和这里的碰不到历史是同一扇门的两面——进料口能改账本不能碰。E3 实验把这事钉死实验有两幕一幕看面板滤掉的东西真没显示二幕追问模型你中间是不是嘀咕过什么它把「我先核对一下船期和货物信息。」原样复述出来——复述自证历史没被滤过。它还背得出这票货的订舱号——记忆之外另有一根会话的轴第 7 节留了钩子。两幕一对边界就画出来了中间件管过程不管记忆模型的记得是从完好历史里长出来的不是背一份被加工过的稿子。为什么设计成这样历史是会话的账本给扩展点开写入口等于给插件改账本的权力——框架把这扇门焊死了中间件只能在进料口和出口动手术。连带推论onSystemPrompt 的章生效于过程、不入史关开关即复原零残留。5.3 管道与洋葱的分界「人设注入位」一节埋的分界线在这正式立起来。毕业检查里同学的原话是「一个任意插手一个管道」——方向对判据得补全看加工对象有没有流。对象是 Flux流里有中间事件包得住、插得进手、半路能改道这是洋葱对象是 String一页文稿只能接住改完递下去这是管道。onSystemPrompt 因此是管道其余四个钩子是洋葱层。官方文档在 onActing 那页留了句硬边界onActing 只覆盖 agent 运行时内的工具执行。这句一次说清两件事。一外部代码直接 Toolkit#callTool 不经过中间件想被审计就别绕开 agent 发调用。二单工具参数级的拦截——create_booking 的货量超限就拒这类——归 checkPermissions 管不归中间件。若有人把订舱参数校验塞进 onActing请他撤回来切面只管横切关注点业务校验塞进切面层就是放错层真要拦checkPermissions 和工具自己都有正门。到此第 2 节的伏笔也收齐了工具这根管上运行时审计归 onActing参数级拦截归 checkPermissions一根管两个闸口各管一段别串门。6、文档没告诉你的事这课翻文档照例踩了漂移第 8 处性质跟前七处还不一样。文档写的接口面是六个钩子多出来的叫 onAgentStateReady签名void onAgentStateReady(Agent, RuntimeContext, AgentState, ListMsg)还有 activePoints() 和 ExtensionPoint 枚举 6 个值。我们 pin 的 jar 是 2.0.3javap 一开五个钩子这些全没有。有同学问「onAgentStateReady 是不是更新就有了啊」后来一查还真是。版本清单是在 repo1 上翻到的2.0.4 就挂在 2.0.3 旁边拉三个 jar 逐个裁决读表文档追新、制品滞后文档写的是 2.1.0-BETA1 的 API 面多数人拉到的稳定版还停在五钩子。前七处漂移是文档写错了这一处是文档写了你还没有的东西别照文档抄第六个钩子除非你真换到了 BETA。第二件是「人设注入位」一节埋过的嵌套图把 onSystemPrompt 画在 loop 里照图会读成每轮一次2.0.3 实测每运行一次114→171 那行每个运行各有一条。图和实测不一致时以实测为准这课的规矩。顺带一个实操提醒在 2.0.3 上照文档抄第六个钩子Override 直接标红——接口里压根没这方法编译器先替文档拦一道。第三件是个新旧并存的彩蛋。jar 里有个 hook 包单 onEvent 回调加 priority() 排序HookEventType 12 个值细到 CHUNK 级还有现成的 JsonlTraceExporter 能把事件落成 JSONL——排查翻不动控制台的时候挂上它整场事件流就是一份结构化文件。但这包零文档命名一股上一代味道官方把注押在 middleware 上。课后有同学问「hook 后面会讲吗」——已经顺便讲了对照一句话Hook 是单入口 onEvent 加 priorityMiddlewareBase 是五钩子加 order。你的代码永远写 middlewarehook 认识即可0020 要是露头再说。6.1 三步证据链方法论收尾。order 那行 sort同事问「这个是源码吗」的时候我贴的是字节码直译的等价 Java后来拉 GitHub v2.0.3 tag 到 5327 行逐字对一字不差连稳定排序保注册顺序的注释都在。三步走字节码钉契约源码对字面实测裁行为。三步各有分工字节码回答jar 里到底有什么源码回答这行字是不是它实测回答跑起来到底什么行为——文档不在三步里它说的是应该有什么三步回答的是实际有什么。前八处漂移全是这三步兜出来的。7、结尾带走三句中间件不改一行框架代码把自己的逻辑套上 agent 执行流覆写哪个钩子就是你的切点表达式。两层中间件不是流程跑两遍递给每层的 next 不同351 事件双层同流日志翻倍不等于执行翻倍。中间件管过程、碰不到历史单工具参数校验归 checkPermissions切面别干业务的活。临走三问场景都给了答案自己找要让 VIP 租户的菜单里多一个改舱工具、普通租户看不见你写进哪个钩子顺手说说为什么写 onActing 不行。同事往 builder 里塞了三个中间件跑完发现 onAgent 的 pre 出现两轮共四条他喊框架有 bug——你让他先数什么你把中间轮自言自语滤掉了测试追问模型你刚才嘀咕了啥它原样背出来——这算滤失败吗三道都答对了这一课才算真的带走。系列走到第 7 课从消息与事件模型、循环引擎一路拆到上节课的权限与 HITLagent 的里子看得差不多了这一课是第一次不改框架还能往里加东西。下节课 0008 讲会话机制今天留的两个彩蛋到时一起回收模型为什么记得订过舱新会话的第一笔订舱编号为什么是 2 号。如果这篇文章对你有帮助欢迎转发给需要的同事和朋友。