AgentScope 生产级 Hook 实战:三次提交,把 LLM 的「概率」关进工程的「确定性」笼子
我的项目地址:https://github.com/liulangjietou/customer_work
我的项目地址:https://github.com/liulangjietou/customer_work
我的客服 Agent,system prompt 里白纸黑字写着第 4 条:「涉及资金的退款只生成待人工确认工单,绝不承诺已直接打款」。结果某天它对用户回了一句:「已为您退款 500 元,请注意查收。」
它没调任何退款工具。它就是顺着对话,把这句话「说」出来了。
我把提示词又加粗又加感叹号,写到第 8 条。下次它还是会犯。因为这不是提示词写得不够狠的问题,这是概率问题。LLM 在采样,软约定堵不住采样。
能堵住的东西,叫 Hook。
这篇文章拆 AgentScopeJava(下面简称 ASJ)feat/hooks 分支最近的三次提交。不讲概念,讲一下Agent改造成能上生产的真实工程是怎么用 Hook 一刀一刀做出来的。三次提交,一条清晰的生产化路线:
1a1b1f1 补四个能力(延迟 / 脱敏 / 审计 / 自我纠错)+ 让 Hook 可插拔
bf032d1 修一个会反噬主链路的隐蔽 bug
3c28213 再补五类缺口(工具护栏 / 结果脱敏 / 总结观测 / 动态参数 / 全局热插拔)
代码全部来自仓库真实文件,下面贴的每一段都能在源码里逐字找到。
一、先把 Hook 讲清楚:它不是埋点,是介入点
很多人对 Hook 的理解停在「回调」「打个日志」。这是把它看小了。
ASJ 的 Hook 接口长这样,核心就两个方法:
publicclassLatencyHookimplementsHook{
@Override
public <T extends HookEvent> Mono<T> onEvent(T event){
if (enabled) {
try { handle(event); }
catch (Exception e) { log.debug("[LAT] 延迟埋点异常(已忽略): {}", e.getMessage()); }
}
return Mono.just(event); // 只读透传,原样放行
}
@Override
publicintpriority(){ return50; } // 数值越小越先跑
}
onEvent 拿到一个事件,做点事,返回。priority 决定它在一串 Hook 里的执行顺序。看着平平无奇。
关键在于 event 的种类,以及 event 上能调的方法。ASJ 把 Agent 的一次完整运行,切成了十类生命周期事件点:

把这张图看懂,这篇文章就懂了一半。重点是上下两排便签里的动词:
PreReasoning | setGenerateOptions | 介入 |
PostReasoning | gotoReasoning | 介入 |
PreActing | setToolUse | 介入 |
PostActing | setToolResult | 介入 |
PostCall | setFinalMessage | 介入 |
ReasoningChunkError |
一句话定性:观测型 Hook 只能看,介入型 Hook 能改。能改推理参数、能改工具入参、能改工具结果、能改最终回复、还能把整轮推理推倒重来。这已经不是「回调」,这是给运行中的 Agent 做活体手术的介入点。
下面三次提交干的所有事,本质上都是在这几个介入点上做文章。
二、可插拔:声明一个 Bean,就排进了流水线
生产代码和教程代码的第一个分水岭,是能不能在不改框架、不改主流程的前提下,把能力加进去。
1a1b1f1 这次提交,把 Hook 做成了 Spring 风格的可插拔。Agent 工厂的构造函数里注入了一个 ObjectProvider<Hook>:
publicCustomerServiceAgentFactory(/* ... */,
ObjectProvider<Hook> pluggableHooks, // 本库内置 + 下游自定义的所有 Hook Bean
ObjectProvider<MeterRegistry> meterRegistryProvider){
this.pluggableHooks = pluggableHooks;
}
建每一个会话 Agent 的时候,按优先级顺序,把容器里所有的 Hook Bean 一次性织进去:
// 可插拔 Hook:内置(延迟/脱敏/审计/自我纠错)+ 下游自定义 Hook Bean 一并织入
if (pluggableHooks != null) {
pluggableHooks.orderedStream().forEach(builder::hook);
}
就这五行。带来的结果是:下游业务方想加一个自己的 Hook,只需要声明一个实现了 Hook 接口的 Spring Bean,零改动自动接入,连这个工厂都不用碰。

orderedStream() 这个细节别放过。它会按每个 Hook 的 priority() 排好序再交出来。所以优先级不是装饰,它直接决定了脱敏、审计、护栏这些动作谁先谁后。这一点在第七节会要命,先记着。
三、三次提交,补了哪些「玩具到生产」的缺口
官方示例里的 Hook,基本是「打印一下输入输出」级别的,用来教学。真要上线,缺的东西列出来是这样:
LatencyHook | |||
MaskingHook | |||
MaskingHook | |||
AuditHookAuditSink | |||
SelfCorrectionHook | |||
AuthHook 思路 | ToolGuardHook | ||
DynamicGenerateOptionsHook | |||
GlobalHookRegistry |
一次提交补不完,所以分了三次。第一次补四个 + 打通可插拔,第二次专门修一个 bug,第三次把剩下的治理类能力补齐。下面挑最有代表性的几个拆开看。
四、四个生产级 Hook,每个都对着一个真实的坑
4.1 LatencyHook:延迟不能靠「感觉」
「我们 Agent 挺快的」这种话,在生产里一文不值。慢的是 P99,不是平均数;用户体感差的是首字慢,不是总耗时长。
LatencyHook 把一次请求的耗时拆成五段,每段接一个 Micrometer Timer,而且带分位:
Timer.builder(metric)
.publishPercentiles(0.5, 0.95, 0.99) // P50 / P95 / P99
.publishPercentileHistogram()
.register(meterRegistry);
最有意思的是首字时间(TTFT)。它的算法是:把第一个流式推理分片到达的时刻,减去整次调用的起点。而且一次请求只记一次:
} elseif (event instanceof ReasoningChunkEvent) {
// 首个推理增量分片视为“首字”,每次请求只记一次
if (ttftRecorded.add(agent)) {
Long callStart = startNanos.get("call:" + agent);
if (callStart != null) {
record(M_TTFT, null, now - callStart);
}
}
}
ttftRecorded.add(agent) 这个写法,用 Set 的「加进去成功才算第一次」做幂等去重,省了一个判空加赋值。细,但是对的。
默认开启,没接 Micrometer 就降级成 DEBUG 日志,不强依赖。这是生产组件该有的样子。
4.2 MaskingHook:改输出,但死活不改入参
脱敏听起来简单,正则一套完事。难的是边界:脱什么、在哪一步脱。
MaskingHook 在 PostCall 这一步,对发给用户的最终回复里的手机号、身份证、银行卡、邮箱做掩码:
if (event instanceof PostCallEvent pce) {
Msg masked = maskMessage(pce.getFinalMessage());
if (masked != null) {
pce.setFinalMessage(masked); // 只改对外输出
}
}
注意它重建消息时,只换文本块,role、name、metadata 原样保留。但真正的功力在源码注释这一句:
脱敏只用于对外输出与审计记录,不用于改写工具真实入参(那会破坏 orderId 等业务参数)。
想象一下:如果图省事,在 PreActing 把工具入参也一起脱敏了。用户的订单号 2024110512345678 被当成银行卡号打了码,传给查询工具的就是一串星号,整个业务直接崩。脱敏改输出不改入参,这条边界划错,功能就坏。这是介入型 Hook 的代价:能力越大,划错边界的后果越严重。
4.3 AuditHook:审计和监控,不是一回事
很多人把审计和可观测混为一谈。AuditHook 把这件事分得很清楚:
ObservabilityHook关心的是聚合指标(QPS、延迟分位、token 量)。AuditHook关心的是逐条可追溯:谁、何时、调了什么工具、入参是什么、最终决策是什么。
涉资金的高风险操作,事后要能一条条查。所以它写的是「只追加」轨迹,落地端做成了可替换的 SPI:
publicinterfaceAuditSink{
voidrecord(String type, Map<String, Object> fields);
}
默认实现把记录写到一个专用 logger customer-work.audit,你可以在 logback 里单独把它路由到审计文件。下游要把审计投到 Kafka、数据库或 SIEM,声明自己的 AuditSink Bean 就行,默认实现用 @ConditionalOnMissingBean 自动让位。接口在框架手里,落地在业务手里,这是组合优于继承的标准打法。
4.4 SelfCorrectionHook:把软约定升级成硬兜底
回到开头那个事故。提示词管不住的「越权承诺打款」,SelfCorrectionHook 用 gotoReasoning 来管:
privatevoidmaybeCorrect(PostReasoningEvent event){
Msg reasoning = event.getReasoningMessage();
String text = reasoning == null ? null : reasoning.getTextContent();
if (text == null || text.isBlank() || !promisesPayment(text) || hasAnyToolUse(reasoning)) {
return;
}
// ……次数上限校验……
event.gotoReasoning(correctionMsg()); // 推翻这一轮,强制重新推理
}
逻辑是:模型在这一轮里,没调任何工具,纯文本,却出现了「已退款 / 已打款」这类承诺措辞,就判定为越权,注入一条系统纠错提示,逼它重新作答。
.textContent("【系统纠错】你在没有调用退款工具的情况下承诺了已退款/已打款。"
+ "涉及资金的退款必须先调用退款工具走人工确认流程,不得直接声称已打款。"
+ "请重新作答:要么调用退款工具,要么明确告知用户退款将进入人工复核而非已完成。")
为了不让它无限纠错,带了每会话纠错次数上限。默认关闭,因为会多一轮推理,有成本。
到这里,「涉资金必须走工具」这条业务约束,就从提示词里一句可能被采样无视的软约定,变成了运行时一道绕不过去的硬兜底。这就是介入型 Hook 的价值。
五、bf032d1:一行 diff 里的「生产级」与「玩具级」
如果说前面是加功能,那 bf032d1 这次提交,才是这篇文章最想让你看的东西。因为它暴露了一个真理:Hook 既然能改写链路,就一定能写坏链路。
SelfCorrectionHook 第一版的判断条件是 callsRefundTool:只要这一轮没调「退款工具」,就触发纠错。听起来没问题。
问题出在:模型在给出承诺的那一轮,可能同时还调了别的工具,比如 queryOrder。这时候 callsRefundTool 返回 false(确实没调退款工具),于是 gotoReasoning 被触发,往消息流里注入了一条纠错消息。
但这一轮里那个 queryOrder 的 ToolUse 还等着对应的 ToolResult 呢。你凭空插一条没有 ToolResult 的消息进去,框架的 ToolValidator 当场校验失败抛异常。一个本来是兜底的 Hook,把主链路给搞崩了。

修复就是把触发条件从「没调退款工具」收紧成「这一轮压根没调任何工具」。看 diff:
- if (text == null || text.isBlank() || !promisesPayment(text) || callsRefundTool(reasoning)) {
+ // 仅在“纯文本最终答复(无任何工具调用)却承诺打款”时纠错:
+ // 若该轮含工具调用,gotoReasoning 注入的消息缺少对应 ToolResult 会触发框架校验异常,
+ // 且此时模型尚在行动而非给出最终承诺,不应打断。
+ if (text == null || text.isBlank() || !promisesPayment(text) || hasAnyToolUse(reasoning)) {
return;
}
配套把那个按工具名匹配的复杂判断,简化成一个「这轮有没有工具调用」的判断,连 refundTools 配置项一起删掉:
- private boolean callsRefundTool(Msg reasoning) {
- if (!reasoning.hasContentBlocks(ToolUseBlock.class)) return false;
- for (ToolUseBlock use : reasoning.getContentBlocks(ToolUseBlock.class)) {
- if (use.getName() != null && refundTools.contains(use.getName())) return true;
- }
- return false;
- }
+ /** 该轮推理是否包含任何工具调用(含则视为模型在行动,不纠错)。 */
+ private boolean hasAnyToolUse(Msg reasoning) {
+ return reasoning.hasContentBlocks(ToolUseBlock.class);
+ }
逻辑更简单,行为更安全。「模型还在调工具 = 它在行动,不是在给最终承诺,不该打断」,这个判断才是对的。
用介入型 Hook 的时候,你不只要想「我要改什么」,还要想「我改完之后,框架对链路完整性的校验还成不成立」。gotoReasoning、setToolUse、setToolResult 这些方法,每一个都可能让你和框架的内部不变量打架。这是教程永远不会教,只有线上崩一次才会记住的东西。
六、3c28213:把「治理」做到入参和推理参数上
第三次提交补的五个能力里,挑三个最能体现「治理思维」的。
6.1 ToolGuardHook:在工具执行前管住它的手
PreActing 这个介入点,对应官方 AuthHook 的思路,但做得更狠。它干两件事:
// 1) 公共参数注入(缺失才注入):把渠道/租户/来源注进每个工具调用
for (Map.Entry<String, String> e : injectParams.entrySet()) {
if (!input.containsKey(e.getKey())) {
input.put(e.getKey(), e.getValue());
changed = true;
}
}
// 2) 数值上限钳制:退款金额这类参数超限,直接改写为上限并告警
for (Map.Entry<String, Double> cap : numericCaps.entrySet()) {
Double value = toDouble(input.get(cap.getKey()));
if (value != null && value > cap.getValue()) {
log.warn("[GUARD] 工具 {} 参数 {}={} 超过上限 {},已钳制",
use.getName(), cap.getKey(), value, cap.getValue());
input.put(cap.getKey(), cap.getValue());
changed = true;
}
}
模型万一抽风给了个退款 999999,护栏在工具真正执行前把它钳到上限。资金类操作,宁可拦错,不能放过。它的优先级是 20,系统级,在所有业务 Hook 之前先把入参治理干净。
6.2 DynamicGenerateOptionsHook:按意图切档位
闲聊和投诉纠纷,用同一套生成参数是浪费。这个 Hook 在 PreReasoning 里,命中「投诉 / 退款 / 纠纷」这类高风险关键词时,临时切到「精确档」:低温度、高推理强度。
GenerateOptions override = GenerateOptions.builder()
.temperature(preciseTemperature) // 更低温,更稳
.reasoningEffort(preciseReasoningEffort) // 更高推理强度
.build();
// 前者非空值优先:只覆盖温度/推理强度,其余沿用原有 effective 参数
GenerateOptions merged = GenerateOptions.mergeOptions(override, pre.getEffectiveGenerateOptions());
pre.setGenerateOptions(merged);
mergeOptions(override, effective) 这个语义要记牢:override 里非空的字段覆盖,空的字段沿用原值。只动该动的两个参数,不碰别的。敏感场景更稳,普通对话不受影响。
6.3 GlobalHookRegistry:横切关注点的热插拔
前面所有 Hook 都是「建 Agent 时织入」的会话级 Hook。但全局鉴权、全局审计、灰度埋点这种横切关注点,你希望它对所有 Agent 生效,而且能运行时开关,不重启。
ASJ 的 AgentBase 提供了系统级 Hook,GlobalHookRegistry 把它封装成可管理的注册中心:
publicvoidregister(Hook hook){
AgentBase.addSystemHook(hook); // 对“之后创建”的所有 Agent 生效
registered.add(hook);
}
@PreDestroy
publicvoidclear(){
for (Hook hook : registered) {
AgentBase.removeSystemHook(hook);
}
registered.clear(); // 应用关闭时清理,防止泄漏到后续上下文
}
@PreDestroy 这个清理动作不能省。系统 Hook 是进程级全局状态,注册了不反注册,测试之间、上下文之间会互相串。注册中心自己记着注册了哪些,关闭时全清掉。
会话级和全局级,用途完全不同,别混:
builder.hook() | AgentBase.addSystemHook() | |
顺带一个诚实的细节:这次提交说明里写了,PreCallEvent.setSystemMessage / appendSystemContent 是 ASJ 2.0 才有的能力,当前用的 1.0.12 还没有,所以这块没硬做,留着升级再补。认版本边界,不为了凑功能而硬上,这也是生产代码的态度。
七、四条生产铁律
把三次提交里反复出现的工程判断,提炼成四条。
铁律一:Hook 只读透传,异常一律自己吞,绝不打断主链路。每个 Hook 的 onEvent 都包了 try/catch,出了问题只记日志,照样 return Mono.just(event)。一个埋点 Hook 挂了,不能把用户的请求带走。bf032d1 那个 bug 的本质,就是违反了这条。
铁律二:优先级即顺序,治理在前,脱敏在后。还记得第二节的 orderedStream() 吗?看这串数字就明白为什么它重要:
审计(850)必须排在脱敏(900)前面。反过来的话,审计记下的就是已经打了码的文本,事后追溯直接失效。顺序错了,功能就废了。
铁律三:改输出不改入参,边界划死。脱敏作用于对外回复和审计记录,绝不碰工具真实入参。介入型 Hook 的能力是把双刃剑,边界越清楚,越不容易自伤。
铁律四:fast-fail 的兜底只留一处,别处处设防。所有 Hook 共用一个 SensitiveDataMasker,脱敏规则预编译一次。审计入参要不要脱敏,一个 maskArgs 开关控制,复用同一个脱敏器。防御逻辑高度复用,不在每个 Hook 里各写一遍正则。
最后给个可信度的锚:这三次提交不是空谈。1a1b1f1 配了 27 个单测,3c28213 配了 52 个,全量 mvn test 绿,仓库 README 记录的是 176 个单测全绿(4 个按外部服务可用性自动跳过)。生产级和玩具级的另一个区别,是前者每加一刀都有测试兜着。
写在最后
Demo 谁都能跑。给个 model、给个 toolkit、ReActAgent.builder() 一把梭,五分钟就能让 Agent 跟你聊起来。
但 Demo 和生产之间,隔着的就是这些没人写进 quickstart 的 Hook:延迟要拆到 P99 和首字,回复要脱敏,决策要审计,越权要硬兜底,入参要钳制,参数要按意图切档,横切关注点要能热插拔。每一条,都是线上踩过的坑变成的代码。
Hook 不是 ASJ 的边角料。它是你把 LLM 那点不确定的「概率」,关进工程「确定性」笼子的那道闸门。
喜欢就点个【关注】,期待更多高质量内容
