发布信息

把 AgentScope推上生产线Hook

作者:本站编辑      2026-06-24 10:02:24     0
把 AgentScope推上生产线Hook

AgentScope 生产级 Hook 实战:三次提交,把 LLM 的「概率」关进工程的「确定性」笼子

我的项目地址: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 的一次完整运行,切成了十类生命周期事件点:

把这张图看懂,这篇文章就懂了一半。重点是上下两排便签里的动词:

事件点
它能干什么
区别
PreReasoningsetGenerateOptions
 改这一轮的温度、推理强度
介入
PostReasoninggotoReasoning
 把这一轮推翻,强制重推
介入
PreActingsetToolUse
 在工具执行前改写它的入参
介入
PostActingsetToolResult
 改写工具返回的结果
介入
PostCallsetFinalMessage
 改写发给用户的最终回复
介入
ReasoningChunk
 / Error
拿到流式分片、拿到异常
观测

一句话定性:观测型 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,基本是「打印一下输入输出」级别的,用来教学。真要上线,缺的东西列出来是这样:

生产诉求
官方示例 Hook
这套生产级 Hook
落点事件
延迟 SLO(P99 / 首字时间)
LatencyHook
PreCall→PostCall / ReasoningChunk
对外回复脱敏
MaskingHook
PostCall
工具结果脱敏
MaskingHook
 扩展
PostActing
合规审计可追溯
AuditHook
 + AuditSink
PostActing / PostCall / Error
越权承诺硬兜底
SelfCorrectionHook
PostReasoning
工具入参治理
仅 AuthHook 思路
ToolGuardHook
PreActing
按意图动态调参
DynamicGenerateOptionsHook
PreReasoning
全局热插拔
GlobalHookRegistry
运行时 add/remove

一次提交补不完,所以分了三次。第一次补四个 + 打通可插拔,第二次专门修一个 bug,第三次把剩下的治理类能力补齐。下面挑最有代表性的几个拆开看。


四、四个生产级 Hook,每个都对着一个真实的坑

4.1 LatencyHook:延迟不能靠「感觉」

「我们 Agent 挺快的」这种话,在生产里一文不值。慢的是 P99,不是平均数;用户体感差的是首字慢,不是总耗时长。

LatencyHook 把一次请求的耗时拆成五段,每段接一个 Micrometer Timer,而且带分位:

Timer.builder(metric)
     .publishPercentiles(0.50.950.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 的时候,你不只要想「我要改什么」,还要想「我改完之后,框架对链路完整性的校验还成不成立」。gotoReasoningsetToolUsesetToolResult 这些方法,每一个都可能让你和框架的内部不变量打架。这是教程永远不会教,只有线上崩一次才会记住的东西。


六、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 是进程级全局状态,注册了不反注册,测试之间、上下文之间会互相串。注册中心自己记着注册了哪些,关闭时全清掉。

会话级和全局级,用途完全不同,别混:

维度
会话级 Hook
全局系统 Hook
织入方式
builder.hook()
 建 Agent 时
AgentBase.addSystemHook()
作用范围
当前这个 Agent
之后创建的所有 Agent
生命周期
跟着 Agent 走
进程级,需手动反注册
适用场景
延迟 / 脱敏 / 审计 / 纠错
全局鉴权 / 全局审计 / 灰度
风险
隔离,互不影响
全局状态,不清理会泄漏

顺带一个诚实的细节:这次提交说明里写了,PreCallEvent.setSystemMessage / appendSystemContent 是 ASJ 2.0 才有的能力,当前用的 1.0.12 还没有,所以这块没硬做,留着升级再补。认版本边界,不为了凑功能而硬上,这也是生产代码的态度。


七、四条生产铁律

把三次提交里反复出现的工程判断,提炼成四条。

铁律一:Hook 只读透传,异常一律自己吞,绝不打断主链路。每个 Hook 的 onEvent 都包了 try/catch,出了问题只记日志,照样 return Mono.just(event)。一个埋点 Hook 挂了,不能把用户的请求带走。bf032d1 那个 bug 的本质,就是违反了这条。

铁律二:优先级即顺序,治理在前,脱敏在后。还记得第二节的 orderedStream() 吗?看这串数字就明白为什么它重要:

Hook
优先级
为什么是这个位置
ToolGuardHook
20
系统级,最先把入参治理干净
LatencyHook
50
尽早打点,贴近真实边界
DynamicOptionsHook
70
推理前调好参数
SelfCorrectionHook
200
推理后、观测前评估结果
AuditHook
850
出站脱敏前,留住原始决策文本
MaskingHook
900
最后一步,对外输出脱敏

审计(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 那点不确定的「概率」,关进工程「确定性」笼子的那道闸门。

喜欢就点个【关注】,期待更多高质量内容

相关内容 查看全部