Agent账单多了一倍?从 OverAllState 到 Hook 的 ReactAgent 控制面全解(Java 架构师的 AI 工程笔记 10)

张开发
2026/5/24 10:54:44 15 分钟阅读
Agent账单多了一倍?从 OverAllState 到 Hook 的 ReactAgent 控制面全解(Java 架构师的 AI 工程笔记 10)
ReactAgent 运行时拆解State、Hooks 与 Interceptors 工程实践票小蜜上线不到一周某天早上打开账单当日 Token 消耗是前几天均值的两倍出头。账单里没有明细只知道贵了。想搞清楚为什么得去翻 Spring Boot 的运行日志——那是一堆混在一起的INFO行没有调用次数没有上下文大小也没有这一次 ReAct 循环执行了多少轮这种结构化信息。拼了将近一个小时才把一条可疑记录的执行链拼出来一个用户问了一句帮我改签下午的航班。从第一次 LLM 调用到最后停止ReAct 循环跑了 9 轮——查余票、查价格、查改签政策反复确认最后还是没改成模型在第 9 轮输出了一句抱歉我无法完成此操作然后停了。每次 LLM 调用都把完整的消息历史塞进去到最后一轮上下文已经超过 7000 token。更让人头疼的是那一个小时是花在看懂发生了什么上而不是修问题上。没有调用计数不知道这 9 轮是正常还是异常没有每轮的 token 统计不知道上下文什么时候开始膨胀不知道模型在哪一轮开始迷失也不知道什么时候该踩刹车。能跑的 Agent和可控的 Agent之间的差距就在这一刻变成了真实的工程负担。这一章要打开的就是 ReactAgent 的运行时控制面。不是 API 文档的翻译而是从工程判断的角度把三件事讲清楚OverAllState图的共享内存数据怎么存、怎么更新、会话间怎么活着Hook生命周期钩子在哪个位置挂载、能干什么、怎么优雅地停止Interceptor调用链过滤器和 Hook 的边界在哪、什么场景才用它把这三件事搞清楚票小蜜才算真正可控。系列目标从零构建机票客服型 Agent「票小蜜」本篇位置第 10 章 / Agent Runtime 控制面前置知识第 09 章《从 ChatClient 到 Agent Runtime》一、运行时控制面的三个层次先把全局结构放出来建立坐标系后面每一节都在这个框架里展开。层次作用域核心能力工程类比State 层整个图的共享数据读写运行时状态跨节点传数据数据库事务的共享内存Hook 层图节点级别在特定位置插入自定义逻辑可以改 StateSpring AOP 的Before/AfterInterceptor 层单次模型/工具调用拦截请求和响应做过滤和修改Servlet Filter Chain三个层次的边界不是随意划定的——它们对应三种不同的控制粒度。State 是数据层Hook 是流程层Interceptor 是调用层。搞混了就会出现用 Hook 做了应该用 Interceptor 做的事或者反过来。二、OverAllState图的共享内存先从问题说起票小蜜在改签航班时需要在多个步骤之间传递信息用户说改到下午三点那班查余票时查出来了 CA1234改签确认前还要再用一次这个航班号。在传统的方法调用链里这很简单——A 方法返回flightNoB 方法接收它作为参数。但 ReactAgent 内部是 StateGraphAgentLlmNode和AgentToolNode在 ReAct 循环里反复交替执行它们之间没有直接的方法调用关系无法通过参数传数据。最简单的想法是把flightNo塞进 messages让它随着对话历史流转。但这会带来两个问题每轮对话消息都发给模型——你的flightNoCA1234会出现在 LLM 的上下文里消耗 token甚至干扰模型的判断没有结构只能靠自然语言——你没法做state.get(flightNo)只能从消息文本里解析这本质上是把结构化数据非结构化了OverAllState 就是解决这个问题的它是一个独立于 messages 的共享容器所有节点和 Hook 都能读写它数据不会发给 LLM但会随着图的执行一直活着。OverAllState 的结构OverAllState本质是MapString, Object。框架默认只有一个 keymessages → ListMessage // 对话历史发给 LLM你可以加任意业务 keymessages → ListMessage // 默认对话历史 flightNo → CA1234 // 当前处理的航班号不进 LLM 上下文 userTier → vip // 用户等级Hook 读取用于动态限流 llmCallCount → 3 // 本轮调用次数Hook 写入用于监控什么时候需要加自定义 key判断标准很简单场景用 messages用自定义 key对话历史需要发给 LLM✓业务状态订单号、航班号不需要 LLM 知道✓Hook 需要读取的上下文用户等级、调用计数✓节点间传递的结构化中间结果✓OverAllState 固定是MapString, Object不能换成强类型 POJO——这是框架为了让所有节点和 Hook 都能无感访问状态的设计权衡。读取时自己做类型转换(String) state.value(flightNo).orElse()。和 ToolContext、Context Engineering 的区别引入 OverAllState 之前Spring AI 已经有一个叫toolContext的机制很容易和它混淆Context Engineering 这个概念也经常在同一个场景里被提到。三者的边界需要说清楚。ToolContext请求级的只读注入toolContext是在发起agent.call()时一次性传入的键值对工具实现里可以读到它但写不回去——它是单向的、请求范围内有效的。// 调用方设置把当前用户信息注入给工具RunnableConfigconfigRunnableConfig.builder().threadId(sessionId).build();// 工具里通过 ToolContext 读取Tool(queryUserOrders)publicStringqueryOrders(Stringstatus,ToolContextctx){StringuserId(String)ctx.getContext().get(userId);// 读不能改returnorderService.query(userId,status);}OverAllState解决的是不同的问题工具执行完之后它的结果或从结果里提取的结构化数据如何流转到下一个节点。ToolContext 给不了这个——它没有写回的能力也不跨节点存活。维度ToolContextOverAllState生命周期单次agent.call()整个图执行跨 call 持久化读写只读工具只能读节点和 Hook 均可读写数据来源调用方在外部注入节点和 Hook 在执行中写入典型用途传入租户 ID、登录用户等执行环境在节点间流转的业务状态Context Engineering管理发给 LLM 的 token 里装什么Context Engineering 不是具体 API而是一种设计视角LLM 的上下文窗口是有限的要有意识地决定哪些数据进去、用什么形式进去、什么时候清理。OverAllState 里存着所有数据messages 自定义 key但自定义 key 的数据不会自动进 LLM 上下文——只有messages列表才会被AgentLlmNode送给模型。Context Engineering 发生在 messages 这条链路上OverAllState.messages原始对话历史 │ ├── SummarizationHook把旧消息压缩成摘要写回 messages │ 改变 State 里存的内容 │ └── ContextEditingInterceptor发送前临时删掉旧工具结果 不改 State只过滤发出去的内容 │ ↓ 最终发给 LLM 的上下文简单记ToolContext 是进场时发的工牌OverAllState 是场内共享的白板Context Engineering 是决定白板上哪些内容要念给 LLM 听。两种更新策略决定数据命运往 OverAllState 写数据不是简单的map.put()。每个 key 必须绑定一个KeyStrategy决定新数据进来时如何与已有数据合并AppendStrategy追加。messages用这个——每轮对话的消息自动累积成完整历史。ReplaceStrategy覆盖。业务状态 key 用这个——当前航班号只需要最新的那一个旧的没有意义。自定义 key 在哪里注册策略在 Hook 的getKeyStrategys()方法里声明框架构建时自动收集不需要在 Builder 上额外配置OverridepublicMapString,KeyStrategygetKeyStrategys(){// 框架构建时会把这里的 key 和策略注册到 StateGraph schemareturnMap.of(llmCallCount,newReplaceStrategy());}工程陷阱不要在 Hook 里往messages写东西AgentLlmNode每轮执行后会往messages写一条AssistantMessageAppendStrategy 把它追加进去。如果你的 Hook 返回的 Map 里也带了messageskey同样会被 AppendStrategy 追加——同一条消息就进了列表两遍下次发给 LLM 时 token 翻倍模型还会看到重复上下文。// ❌ 错误Hook 返回 Map 里带 messages会触发 AppendStrategy 再追加一次returnCompletableFuture.completedFuture(Map.of(messages,List.of(someMsg)));// ✓ 正确只观测不写返回空 MapreturnCompletableFuture.completedFuture(Map.of());// ✓ 正确需要存业务数据写自定义 keyReplaceStrategy覆盖语义安全returnCompletableFuture.completedFuture(Map.of(llmCallCount,count1));Hook 返回的 Map 里有什么就追加/覆盖什么——这是写入语义不是更新语义。读 state 不会改变 state但只要返回 Map 里包含messages就会触发追加。CheckpointSaver状态快照与集群问题有了 OverAllState还需要解决跨请求的持久化问题。MemorySaverBaseCheckpointSaver的默认实现在每次图执行完毕后以threadId为 key 把整个 OverAllState 做一次快照下次用同一个threadId请求时恢复。单机没问题集群必须替换。MemorySaver是纯 JVM 内存多实例部署时请求1threadIdabc→ 节点A → 状态写入节点A内存 请求2同一threadId→ 节点B → 节点B内存里没有 → 状态丢失会话断裂扩展点是BaseCheckpointSaver实现它接入 Redis 或数据库publicclassRedisCheckpointSaverextendsBaseCheckpointSaver{privatefinalRedisTemplateString,Stringredis;OverridepublicOptionalCheckpointget(RunnableConfigconfig){Stringjsonredis.opsForValue().get(agent:config.threadId());returnjsonnull?Optional.empty():Optional.of(deserialize(json));}OverridepublicRunnableConfigput(RunnableConfigconfig,Checkpointcheckpoint)throwsException{redis.opsForValue().set(agent:config.threadId(),serialize(checkpoint),Duration.ofHours(24));returnconfig;}OverridepublicCollectionCheckpointlist(RunnableConfigconfig){...}}注册时用.saver()方法不是.checkpointSaver()ReactAgent.builder().saver(newRedisCheckpointSaver(redisTemplate)).build();这意味着 MemorySaver 保存的不只是消息历史而是完整的运行时状态包括你所有自定义业务 key 的值。这是它和普通 ChatMemory 的本质区别。三、Hook生命周期的控制点Hook 是图节点不是回调这里有一个理解上的关键弯Hook 不是监听器不是回调函数而是以图节点的形式插入到 StateGraph 的执行流程中。ReactAgent 内部的 StateGraph 执行流是这样的图上清楚地展示了四个 Hook 位置HookPosition枚举Hook 位置触发时机触发次数BEFORE_AGENT整个 agent.call() 开始前每次 call 触发 1 次AFTER_AGENT整个 agent.call() 完成后每次 call 触发 1 次BEFORE_MODEL每次 LLM 调用前ReAct 循环内可能多次AFTER_MODEL每次 LLM 调用后ReAct 循环内可能多次重要区分BEFORE_AGENT/AFTER_AGENT是每次agent.call()只触发一次的外层钩子而BEFORE_MODEL/AFTER_MODEL是在 ReAct 循环内部触发的工具调用越多触发次数越多。Hook 的返回值会合并回 StateHook 的方法签名返回CompletableFutureMapString, Object——这个 Map 会被合并回 OverAllState按照各 key 注册的 KeyStrategy 处理。这是 Hook 比普通日志拦截器更强大的地方Hook 不只能观测还能修改运行时状态。内置 Hook 先用起来ModelCallLimitHook先把账单问题解决掉。框架内置了ModelCallLimitHook它已经包含了计数和限制两件事不需要自己写ModelCallLimitHooklimitHookModelCallLimitHook.builder().runLimit(5)// 单次 call 最多 5 次 LLM 调用.threadLimit(30)// 同一会话累计最多 30 次.exitBehavior(ExitBehavior.END)// 触发时优雅退出而不是抛异常.build();runLimit单次agent.call()内最多调用几次 LLM。票小蜜改签一个航班正常不超过 5 次就该结束。threadLimit同一threadId整个会话的累计上限。防止一个用户反复追问导致整体费用失控。另一个内置 Hook 是SummarizationHook当消息历史超过 token 阈值时自动把旧消息摘要成一条控制上下文长度。大多数场景用这两个内置 Hook 就够了。什么情况下才需要自定义 Hook内置 Hook 解决的是统一规则超过 N 次就停超过 M token 就摘要。但生产环境里往往有和业务状态绑定的差异化逻辑这是内置 Hook 没法做的。场景 1不同用户等级限制不同免费用户单次 call 最多 3 次 LLMVIP 用户最多 10 次。ModelCallLimitHook的runLimit是静态的没法动态读用户等级ComponentpublicclassUserTierLimitHookextendsModelHook{OverridepublicStringgetName(){returnuserTierLimitHook;}OverridepublicMapString,KeyStrategygetKeyStrategys(){returnMap.of(llmCallCount,newReplaceStrategy());}OverridepublicCompletableFutureMapString,ObjectbeforeModel(OverAllStatestate,RunnableConfigconfig){intcount(Integer)state.value(llmCallCount).orElse(0)1;// 从 State 读用户等级由前置节点或 BEFORE_AGENT Hook 写入StringuserTier(String)state.value(userTier).orElse(free);intlimitvip.equals(userTier)?10:3;if(countlimit){// 写入 jump_toEND图会优雅退出returnCompletableFuture.completedFuture(Map.of(llmCallCount,count,jump_to,END));}returnCompletableFuture.completedFuture(Map.of(llmCallCount,count));}}关键点这个 Hook 从 OverAllState 里读userTier——这是自定义 Hook 才能做的事内置 Hook 没有业务上下文。场景 2对接监控系统而不只是打日志ModelCallLimitHook知道调用了几次但它不会把数据推到 Prometheus 或 SkyWalking。如果你需要按threadId、用户 ID 统计调用量、计算平均轮次需要自定义 Hook 把数据写到监控系统ComponentpublicclassMetricsHookextendsModelHook{privatefinalMeterRegistrymeterRegistry;publicMetricsHook(MeterRegistrymeterRegistry){this.meterRegistrymeterRegistry;}OverridepublicStringgetName(){returnmetricsHook;}OverridepublicCompletableFutureMapString,ObjectafterModel(OverAllStatestate,RunnableConfigconfig){// 每次 LLM 调用完成向监控系统打点meterRegistry.counter(agent.llm.calls,threadId,config.threadId()).increment();returnCompletableFuture.completedFuture(Map.of());// 不修改 State}}判断要不要自定义 Hook 的经验规则需求用内置 Hook自定义 Hook超过 N 次停止ModelCallLimitHook—上下文太长自动摘要SummarizationHook—不同用户有不同上限—读 State 里的用户信息动态决策把调用数据推监控系统—afterModel 里对接 Micrometer/SkyWalking在 State 里存业务数据供后续节点用—在 Hook 里写 OverAllState需要访问 OverAllState—只有 Hook 能做四、Hook vs Interceptor两个层次两种职责这是最容易混淆的部分。图中清楚地展示了核心差异维度HookInterceptor作用域图节点级别跨越整个执行阶段单次模型调用或工具调用能否访问 State可以读写 OverAllState不能只能看到请求和响应能否改变执行流能写入 jump_to 改变图跳转不能只能修改请求/响应内容触发时机图节点执行前后模型 API 调用前后注册方式构建 ReactAgent 时传入 hooks 列表传入 modelInterceptors / toolInterceptors 列表判断用哪个的经验规则需要访问 OverAllState→ 用 Hook需要修改 LLM 的请求prompt、参数或响应→ 用 ModelInterceptor需要修改工具调用的入参或结果→ 用 ToolInterceptor需要控制图的执行流提前退出、跳转→ 只能用 Hook内置 InterceptorContextEditingInterceptor框架提供了ContextEditingInterceptor在消息历史过长时自动清理旧的工具调用结果tool_result 类型消息只保留最近的若干条ContextEditingInterceptorcontextInterceptorContextEditingInterceptor.builder().trigger(6000)// 超过 6000 token 时触发.keep(2000)// 清理后保留最近 2000 token 的内容.clearAtLeast(1)// 至少清理 1 条旧的工具结果.excludeTools(List.of(searchKnowledge))// 这个工具的结果不清理.build();excludeTools是个关键参数有些工具比如知识库查询的结果是整个对话的参考基础不能随便清掉。把这些工具加入排除列表确保它们的结果在整个会话中持续可见。工程判断SummarizationHook和ContextEditingInterceptor看起来功能相似但定位不同。Hook 做的是把旧消息摘要成新消息保留语义Interceptor 做的是在发送给模型前临时删掉一些内容不修改 State。通常两者配合使用而不是二选一。五、三层停止控制Agent 的停止问题比看起来复杂。ReactAgent 有三层停止机制优先级从高到低第一层模型主动停止优先模型输出中没有tool_callReAct 循环判断没有工具要调正常结束。这是最健康的停止方式——说明模型认为任务已经完成或者信息不足以继续。第二层ModelCallLimitHook 优雅退出兜底达到runLimit或threadLimitHook 往 State 写入jump_toEND图跳转到结束节点还能输出一条已达到调用上限的提示消息给用户。第三层maxRecursion 硬中断最后保护StateGraph 本身有一个最大递归深度限制。如果前两层都没有生效比如 Hook 没有正确注册这个兜底机制会强制停止但不会给出友好提示——直接中断。// 配置 maxRecursionReactAgentagentReactAgent.builder().maxRecursion(20)// 硬上限一般设置为 runLimit 的 2-3 倍.hooks(List.of(limitHook)).build();工程建议永远不要只依赖第一层模型主动停止——模型有时会陷入循环。永远不要让第三层成为主要停止机制——用户会看到不友好的错误。合理的设置是第一层作为正常路径第二层作为优雅兜底第三层作为安全网。实践篇票小蜜 AgentConfig 升级把前面所有组件组装起来。注意 Hook 的分工内置 Hook 处理通用规则自定义 Hook 处理业务差异。ConfigurationpublicclassAgentConfig{BeanpublicReactAgentticketAgent(ChatModelchatModel,ListToolCallbacktools,MeterRegistrymeterRegistry)throwsException{// 内置 Hook 1统一限流所有用户都适用的硬上限ModelCallLimitHooklimitHookModelCallLimitHook.builder().runLimit(10)// 最高上限兜底用.threadLimit(50).exitBehavior(ExitBehavior.END).build();// 内置 Hook 2上下文摘要控制 Token 费用SummarizationHooksummarizationHookSummarizationHook.builder().tokenThreshold(4000).summaryModel(chatModel).build();// 自定义 Hook 1按用户等级动态限制从 OverAllState 读 userTier// 适用场景免费用户和 VIP 用户的调用上限不同UserTierLimitHooktierLimitHooknewUserTierLimitHook();// 自定义 Hook 2对接监控系统每次 LLM 调用后向 Micrometer 打点// 适用场景需要在 Grafana 上看各 threadId 的 LLM 调用趋势MetricsHookmetricsHooknewMetricsHook(meterRegistry);// Interceptor上下文裁剪发送给模型前临时剔除旧工具结果ContextEditingInterceptorcontextInterceptorContextEditingInterceptor.builder().trigger(6000).keep(2000).clearAtLeast(1).excludeTools(List.of(searchKnowledge))// 知识库查询结果不裁剪.build();returnReactAgent.builder().name(ticketAgent).model(chatModel).tools(tools).systemPrompt(你是票小蜜专业的机票客服助手...).saver(newMemorySaver())// 单机集群部署换 RedisCheckpointSaver// Hook 顺序tierLimitHook 先判断limitHook 兜底summarizationHook 控长度metricsHook 收尾打点.hooks(List.of(tierLimitHook,limitHook,summarizationHook,metricsHook)).interceptors(List.of(contextInterceptor)).build();}}两类 Hook 的分工一目了然ModelCallLimitHookSummarizationHook内置处理所有 Agent 都适用的通用规则开箱即用UserTierLimitHook自定义因为它需要从 OverAllState 读userTier来做动态判断——这是内置 Hook 做不到的MetricsHook自定义因为它要对接外部监控系统——这是业务基础设施集成框架不可能预置票小蜜的账单问题现在从两个维度解决tierLimitHook按用户等级卡上限免费用户 3 次VIP 10 次limitHook作为绝对兜底SummarizationHook控制单次 call 的上下文长度。架构演进视角从第 09 章的最小闭环到第 10 章的可控运行时架构发生了质变左边是第 09 章的起点3 个核心组件能跑。右边是本章的终点7 个组成部分4 个控制层次。每个标注NEW的组件都对应解决了一个具体的工程问题新增组件解决的问题SafeGuardAdvisor敏感内容在进入 Agent 之前拦截不浪费工具调用UserTierLimitHook免费/VIP 用户差异化限流避免资源被单一用户耗尽ModelCallLimitHook绝对上限兜底防止模型陷入循环失控SummarizationHook对话历史自动摘要Token 费用可控ContextEditingInterceptor上下文窗口临时裁剪不影响 State 持久化MetricsHookLLM 调用次数打点为监控告警提供数据源加了这 6 个组件之后执行路径确实变长了。但每一步都有了可观测性和可控性——这不是过度工程而是让 Agent 从玩具变成生产可用组件的必经之路。评论区聊聊这四个坑我在实际开发中都踩过写出来给你做参考。你在接入 ReactAgent 时有没有遇到类似的问题欢迎在评论区说说你的情况一起看看是不是同一个根因。坑 1messages key 双写导致消息重复症状日志里同一条消息出现两次Token 消耗翻倍。原因在两个节点里都往 messages 写了内容AppendStrategy 把两次写入都追加了。解决messages 的写入权归AgentLlmNode独占自定义节点不要直接写 messages而是写自定义 key。坑 2SummarizationHook 和 ContextEditingInterceptor 配置冲突症状摘要完的消息在发给模型前又被裁剪了摘要内容消失。原因两者阈值设置不合理Interceptor 触发后把刚生成的摘要消息也删掉了。解决让 Interceptor 的trigger阈值高于 Hook 的tokenThreshold确保两者不会在同一轮同时触发。坑 3Hook getName() 重复导致注册失败症状应用启动时抛异常提示 Hook 名称冲突。原因自定义 Hook 没有覆盖getName()方法多个 Hook 用了同一个默认名称。解决每个自定义 Hook 都覆盖getName()返回唯一的字符串标识。坑 4runLimit 和 threadLimit 混淆症状某个用户会话触发限制但短会话用户完全正常或反过来。原因runLimit是单次 call 的限制threadLimit是同一 threadId 的累计限制。解决两者配合使用runLimit防单次超限threadLimit防长期滥用。你在接入 Hook 或 Interceptor 时有没有遇到这四个之外的问题或者对 OverAllState 的更新策略有疑问评论区见。本文代码仓库[GitHub 链接]完成项目后补充系列目录[Spring AI Alibaba Agent 实战系列]上一篇为什么你的 AI 助手只会回一句话——用 Spring AI Alibaba 实现真正的多步推理 Agent如果这篇文章对你有帮助欢迎点赞收藏。有问题欢迎评论区交流。

更多文章