AgentScope第6式·观心循环
AgentScope第6式·ReAct循环
一次 call 到底转了几圈?日志对不上,说明内核没看懂。
[读者] 已经用 AgentScope Java 跑通数字人的工程师
[痛点] 只会调一次 reply(),看不见中间发生了什么
[现在读] 外壳已经搭好,是时候翻开内核了
[读完] 能读 Agent/ReActAgent 源码,能按日志还原每一步
[旧方案] 把 Agent 当成一次请求一次回答的推理服务
|
v
[新需求] 回答必须建立在真实配置和真实工具结果之上
|
v
[冲突] 一次回答背后可能转了多圈,没人看得见
|
v
[后果] 答错查不出来,上限调了没反应,出事只能靠猜
我是老李,做 Java 后端十几年,最近两年在做企业的数字人项目。我们的数字人不是那种只会念稿的播报器,它要能查项目配置、能报当前状态、能在多轮里记住用户刚才说过什么,所以从第一天起,它就注定要接一个真正的 Agent 框架,而不是拼一个 HTTP 请求把大模型包起来。
前五式我们干的事,说到底是把「宿主」搭起来:项目能建、配置能存、页面能跑、LiveKit 能连、推理入口能通。到这一步,数字人已经能说话了,但它说得对不对、为什么对、为什么不对,我们其实并没有真正掌握。因为我们看的始终是「输入一句话、输出一段文本」,中间那一层是黑盒。
这一式,我们把这个黑盒打开。我们不去碰上层那些会话管理、权限、审计、协议适配,那些是外壳的事;这一式只做一件事:把 AgentScope Java 的 Agent 接口和 ReActAgent 读透,然后用一个只用内核的对照实现,把「内核能力」和「外壳能力」的分界线,在数字人这个真实项目里划出来。
一次 reply 到底转了几圈,中间发生了什么?
为什么换了一个 Agent 实例,会话还是会串味?
哪些事是内核必须干的,哪些事其实是外壳该干的?
如果这三个问题答不上来,后面接 Tool、接 MCP、接 Memory、接 Skill,都会变成「照着文档抄一遍,能跑就行」,一旦不跑,就只能猜。
01、故事
上周三的评审会上,产品问了一个特别朴素的问题:用户问「p-1001 这个项目的主题色和开场白是什么」,数字人回答对了,那它到底是怎么答对的?
会议室里安静了三秒。有人说是模型知道;有人说是我们塞进了系统提示词;我说都不是,是它先判断要查配置,然后调了查配置的工具,拿到结果,再组织语言说出来的。产品接着问:那这个「先、然后、再」有记录吗?
没有。我们的日志里只有两行:一行是用户输入,一行是数字人的最终回答。中间那几步,全在 JVM 里蒸发了。
任务是:让数字人的每一次回答,都能从日志里一步一步还原出来,包括它调了哪个工具、传了什么参数、拿回什么结果、又说了什么。
这件事听起来像个可观测性问题,其实不是。可观测性是结果,前提是你得先知道「有哪些步」。在我们没有真正理解 ReAct 循环之前,我们连该记哪几步都列不出来。
当天晚上我翻到 AgentScope Java v2.0.3 源码里的 agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java 和 agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java,才发现前面几式我们一直站在门外看。这一式的故事,从这里开始。
会上问三句
日志只剩一行
答案从哪来
谁也说不清
02、问题
旧做法很简单:把数字人的推理入口写成一个方法,传进去一句用户文本,拿回来一句助手文本,中间什么都不管。这套做法在「单轮问答」场景里没问题,因为本来就没有中间。
可数字人从第 3 式起就不是单轮问答了。它要查项目配置、要读当前主题、要引用开场白和结束语,这些都不是模型脑子里有的东西,必须落到工具上去取。一旦落到工具上,「一次请求一次回答」的假设就塌了:取数据这一步,天然会把一次回答掰成至少两段。
技术上的表现有三个。第一,我们无法判断一次回答到底是模型自己说的,还是查完工具照着念的。第二,我们给它配了迭代上限参数,但调大调小好像对结果没影响,也没人知道这个参数到底管什么。第三,一旦工具出错,整个调用直接抛异常,用户看到的是 500,而不是「我暂时查不到配置」。
这三件事的共同根因是同一个:我们从来没有把一次推理当成一个循环来看。
本篇的完成标准写死三条。第一,能在 v2.0.3 的源码里指出一轮 ReAct 循环的入口、终止条件、中间消息的回灌位置。第二,数字人能新增一个只使用 ReActAgent 的对照实现,跑出一个真实触发多轮的工具调用。第三,能从日志里按顺序还原每一步,包括每一次模型推理和每一次工具结果。
一次请求一次答
中间几步看不见
上限调了没反应
出错只报一句话
03、原理
先把名词的边界钉死。在 AgentScope Java 里,Agent 是可对话的最小单元:给一条消息,还一条消息(或一串消息)。它不负责协议、不负责会话存储、不负责路由分发。这个边界很重要,因为它意味着后面所有「会话怎么管、用户怎么鉴权、消息怎么推给前端」的问题,都不该往 Agent 里塞。
ReActAgent 则是这个最小单元的一种实现:它在一次调用内部跑「推理—行动」循环,直到模型不再要求调用工具为止。ReAct 这个词本身不神秘,就是 Reasoning + Acting,先想,再做,看到做的结果再想。
一轮 call 的骨架,大致是这么几步:把系统提示、历史消息、本轮用户输入组装成上下文;调用模型;如果模型输出里表达了「我要调某个工具」,就执行工具,把结果作为一条新消息追加进消息列表;带着这条新消息再调一次模型;如此往复,直到模型给出一条不再要求工具的回复,循环结束,这条回复就是最终答案。
这里有第一个反直觉判断:ReAct 的「记忆」不是 Agent 的记忆,而是消息列表的记忆。 工具执行的结果是以普通消息的形式回灌给模型的,模型看到的永远只是一段文本。它并不知道自己刚才真的查了数据库,它只知道自己「说了一句要查」,然后「看到了一段结果文本」。所以工具返回什么文本,几乎决定了模型下一轮会怎么想。
这个判断解释了很多现象。比如为什么工具返回一大坨 JSON 时模型容易跑偏——它看到的是噪音,不是数据。比如为什么工具报错时最好像正常结果一样回灌一句人话,而不是让异常冲出去——因为循环需要的是「下一步的输入」,不是「中止信号」。
第二个关键概念是 maxIters。它限制的是循环轮数,不是墙钟时间,也不是 token 数。它存在的唯一理由是:模型可能和工具互相踢皮球,一直不停。所以它是刹车,不是油门。把刹车踩死不会让车跑得更稳,只会让撞墙更贵。
第三个概念是「无状态」。准确的说法是:Agent 实例持有的是依赖引用(模型、记忆、工具集),但它不持有「这一轮 call 走到第几步」的进度状态。进度只活在本次调用上下文里。这个设计的直接后果是:串味不串味,取决于你注入的记忆是不是同一份,而不是取决于你 new 了几个 Agent。
需要说明的是,v2.0.3 中循环终止后的具体返回语义(比如达到 maxIters 上限时是返回最后一条消息还是抛出可捕获的异常),本文不替读者下结论,请以 ReActAgent.java 的源码与对应测试为准。这也是本系列后面要专门建源码研究方法的原因。
循环不在类里
状态不在框里
上限不是超时
回灌才是关键
04、架构
[输入] Msg(user):用户一句自然语言
|
v
[模块] ReActAgent:一次 call 的循环执行者
|
v
[数据/状态] 消息列表:系统提示 + 历史 + 本轮输入 + 中间结果
|
v
[处理] 模型推理 → 工具调用 → 结果回灌 → 再推理 → 收敛
|
v
[输出] Msg(assistant) 最终回答 + 沿途中间消息
边界:循环之内是内核,循环之外是外壳。内核管「一条消息进来,怎么转出最终回答」;外壳管「这条消息从哪个用户来、要不要鉴权、要不要落库、要不要推给 LiveKit」。我们在第 5 式给数字人加的那层封装,属于外壳;本篇讲的 ReActAgent,属于内核。
收益:把推理过程变成可枚举的消息序列之后,可观测性、可测试性、可控性三件事同时成立。你不需要在框架里埋点,框架本身就是事件源。
代价:一次调用从「一次 HTTP」变成「多次模型调用 + 多次工具执行」,延迟和成本都会上去。数字人场景下,延迟直接体现为「数字人反应慢」,这是必须付的代价。
适用条件:任务需要多步、需要外部事实、允许一定的自由度和不确定性。
不适用条件:单轮闲聊、纯格式转换、需要严格确定性流程、工具带不可回滚副作用的场景。
涉及 AgentScope 内部机制时,请优先核对三个官方位置:agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java(Agent 接口契约)、agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java(循环实现)、以及官方文档 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/building-blocks/agent.md (building blocks 中的 agent 章节)。三者对照着看,比只看任何一边都稳。
模型只出意图
工具只认参数
消息串起轮回
外壳只管边界
05、实战一次
环境与版本:JDK 17、Maven 3.9、AgentScope Java v2.0.3,教程分支 chapter/06-react-loop(从第 5 式的分支继续创建)。
依赖:引入 agentscope 的 core 模块。下面的坐标以官方仓库 README 为准,不同小版本的 artifact 名称可能微调,落地前请核对一次。
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-core</artifactId>
<version>2.0.3</version>
</dependency>
配置:模型 API Key 走环境变量,不进代码、不进仓库。这一步和框架无关,但每年都有人把 Key 提交上去。
核心实现第一步,是给数字人做一个只查配置的工具。注意它的返回值刻意做成了纯文本键值对,不是大 JSON——因为按第 03 章的判断,模型看到的就是这段文本。
public class ProjectConfigTools {
private final ProjectConfigRepository repository;
public ProjectConfigTools(ProjectConfigRepository repository) {
this.repository = repository;
}
@Tool(name = "get_project_config",
description = "按 projectId 查询数字人项目配置,返回名称、标题、主题色和开场白")
public String getProjectConfig(
@ToolParam(name = "projectId", description = "数字人项目 ID,例如 p-1001")
String projectId) {
ProjectConfig c = repository.findById(projectId);
if (c == null) {
return "未找到项目配置,projectId=" + projectId;
}
return "projectId=" + c.getId()
+ " | name=" + c.getName()
+ " | title=" + c.getTitle()
+ " | themeColor=" + c.getThemeColor()
+ " | opening=" + c.getOpening();
}
}
第二步,组装一个只使用内核的对照实现。所谓对照,就是这一版里不接任何上层封装,直接把 ReActAgent 用起来。
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new ProjectConfigTools(repository));
Agent agent = ReActAgent.builder()
.name("digital-human-react")
.sysPrompt("你是数字人配置助手。回答涉及项目配置的问题时,必须先调用工具获取真实数据,不要凭记忆作答。")
.model(model)
.toolkit(toolkit)
.maxIters(6)
.build();
需要提醒:上面用到的 builder 方法名、@Tool / @ToolParam 注解、Toolkit 的注册方式,请以 v2.0.3 源码为准核对一次。本文保证的是生命周期描述与官方源码一致,不保证这段代码复制即编译。
第三步,也是本篇真正的验收点:不要只调一次拿最终答案,要订阅中间消息并把它们落成日志。
Msg userMsg = Msg.builder()
.role(MsgRole.USER)
.textContent("p-1001 这个项目的主题色和开场白是什么?")
.build();
agent.stream(userMsg)
.doOnNext(msg -> log.info("role={} | {}", msg.getRole(), brief(msg)))
.blockLast();
启动后,把这条消息发进去,日志里应该能看到至少两轮模型推理,中间夹着一次工具结果。示例输出大致长这样:
[t1] role=USER | p-1001 这个项目的主题色和开场白是什么?
[t2] role=ASSISTANT | 需要查询项目配置 -> get_project_config(projectId=p-1001)
[t3] role=TOOL | projectId=p-1001 | name=数字人示例 | title=… | themeColor=#1E88E5 | opening=你好
[t4] role=ASSISTANT | 项目 p-1001 的主题色是 #1E88E5,开场白是「你好」。
以上为示例输出,未在当前环境实测,实际角色名与消息形态请以 v2.0.3 的消息模型为准。验收标准是:t2 和 t4 是两次模型推理,t3 是回灌的工具结果,能指着这四行说出「循环转了两圈」,这一式就算过了。
一个工具入局
两轮推理成环
日志逐条对齐
才算真的跑通
06、排查
证据链一:以为模型没调工具。
现象是日志里只有最终回答,看不到任何工具痕迹,第一反应是模型偷懒没调。检查方式是去看工具侧的调用计数,发现计数确实是 0。继续查,把入口从「只取最终回答」换成订阅中间消息流,才发现根因不在模型,而在我们的取用方式:最终回答只是一轮循环收敛后的产物,中间的推理与工具结果本来就不在那一个返回值里。修正做法是始终保留中间消息的通道,再决定哪些入库、哪些只打日志。
证据链二:两个项目串味。
现象是运营同学反馈,A 项目的会话里冒出了 B 项目的开场白。第一反应是并发写坏了,查了半天线程安全没有问题。真正的证据来自 Spring 容器:我们把承载历史消息的对象做成了单例 Bean,同一个实例被所有会话共享,于是每次推理都把上一个项目的消息带进上下文。根因和 Agent 实例数量无关,和记忆是否共享有关。修正做法是按会话维度隔离历史,Agent 实例怎么复用是次要问题。
错误尝试: 把 maxIters 从 6 调到 20,期望「想得久一点,答案会更好」。这个尝试错在两处:一是 maxIters 限制的是循环轮数,不是思考深度,模型不会因为上限变高就想得更细;二是它只在模型真的需要多轮时才生效,而我们的问题里工具一次就返回了全部数据,循环第二轮就该收敛了。调大之后唯一确定变化的是最坏情况下的成本和延迟,最坏的那条路径变贵了。
上游贡献判断:本文写作过程中没有核验到明确的 AgentScope Java 缺陷。需要现场核验的事项有两类:一是 maxIters 触顶后的返回语义在文档中是否足够直白;二是中间消息流的角色标注在工具结果这一环上是否与文档描述一致。两者都必须在最小 Demo 里复现、比对 v2.0.3 与官方 main、并检索官方 Issues 与 Discussions 之后,才能决定是记录为候选还是提 Issue。当前没有任何真实链接,不写「已提交」,也不写「已修复」。
先看返回何物
再查实例是否共用
证据不在文档
在源码与日志
07、优化
基于第 06 章的两条证据,V2 做三件事。
第一,把上限和观测都显式化。maxIters 不再用默认值,而是作为一个配置项写进项目配置里,并且在日志里给每一轮循环打上序号,让「转到第几圈」成为一个可以搜索的字段。这样出问题时,你搜的是数字,不是感觉。
第二,把工具返回值瘦身。第 03 章已经说明,模型看到的只是文本。返回整张配置表,等于给模型塞噪音。所以工具层只返回模型真正需要的字段,其余字段留在服务端用于日志和审计。这不是优化性能,是优化模型的输入。
第三,把工具错误变成可读的结果,而不是抛出去的异常。这里必须谨慎:工具内部异常到底是被框架捕获后回灌、还是直接向上传播,取决于 v2.0.3 的实现。所以稳妥的做法是在工具方法内部就把可预期错误转成人话返回,把不可预期错误原样抛出交给框架,并在日志里把两种情况分开记录。
三项改动之后,新的行为是:每一次调用都有一条带序号的完整轨迹,模型看不到无关字段,工具的可预期失败不再炸掉整轮对话。验证方法也很直接:故意传一个不存在的 projectId,日志里应该看到模型收到「未找到项目配置」这句结果后,自己组织语言告知用户,而不是整个 call 抛异常。
上限要显式
观测要显式
结果要瘦身
失败要留痕
08、演进
[同一输入]
|
+--[V1] 只调一次 reply(),只取最终回答
| 代价:过程不可见,答错无法归因
|
+--[V2] 订阅中间消息,显式 maxIters,工具返回值瘦身
| 代价:调用变重,日志量上升,需要治理
|
[Trade-off]
得到:可解释、可排障、可测试 / 失去:一次调用的轻量性 / 边界:只在需要归因与审计的链路上开启
正确性上,V2 并没有让模型更聪明,只是让我们能看见它在做什么,所以「答得对不对」这件事第一次变得可判断。稳定性上,V2 把最坏路径的成本显式暴露出来,风险从「不可见」变成「可计量」。复杂度上,V2 增加了日志与消息通道的治理成本,这部分成本必须由外壳承担,不能塞回内核。
适用范围上,V1 适合验证性的内部试用,V2 适合任何要对用户负责的链路。遗留问题是:日志里的中间消息属于业务数据还是调试数据,需要对存储和脱敏策略做一次专门设计,这一条本次没有解决。
隐式循环靠猜
显式循环可证
得到的是确定性
失去的是随手写
09、洞见
9.1 ReAct 的本质是翻译,不是思考
反直觉判断:模型从来不知道它调用过工具。 它只知道消息列表里多了一段文本。所以 ReAct 做的是两件事的翻译:把模型的意图翻译成工具调用,把工具的结果翻译回文本。理解了这一点,你对工具返回值的态度会彻底改变——它不是给你看的日志,它是模型的输入。工具返回什么,模型接下来就想什么。
9.2 内核与外壳的分界线,就是「一轮 call 之内还是之外」
循环、工具调用、结果回灌、迭代上限,这些发生在一轮 call 之内,属于内核。会话管理、鉴权、审计、协议适配、持久化、推送,这些发生在一轮 call 之外,属于外壳。分界线一旦划清,很多设计争论会自动消失:数字人的记忆该放哪、权限该在哪一层拦、日志该由谁写,都有唯一答案。
9.3 maxIters 是刹车不是油门
反直觉判断:调大 maxIters 通常不会让答案更好,只会让错误更贵。 它保护的是系统,不是质量。真正决定质量的是工具返回的信息密度和系统提示的约束力。把刹车当油门踩,是新手最容易犯、也最难自查的一个错。
9.4 什么时候不该用本章这套东西
需要明确的是,ReActAgent 不是所有场景的默认解。纯单轮闲聊、纯格式转换、纯检索问答,直接调模型更省一层循环和一部分延迟。需要严格确定性流程的场景,比如开户、下单,应该用固定工作流而不是让模型自由决定下一步调什么。工具带不可回滚副作用的场景也要慎重——ReAct 天生带试错性质,同一个工具可能被调用多次,重复发短信、重复扣款都是这么来的。真要在这类场景用,就得在外壳层做幂等,而不是指望模型自觉。
框架给的是环
工程给的是界
能读源码三分
能用框架七分
10、系统落地
原来有什么:数字人已经能建项目、存配置、跑页面、连 LiveKit,推理链路也已经打通,但推理入口是一个黑盒,只能看到输入和输出。
本篇新增什么:一个只使用 ReActAgent 的对照实现,和一条能还原每一步的中间消息轨迹。对照实现的价值不在于它要上线,而在于它是一把尺子——以后任何一层封装让你怀疑「是不是框架的问题」,把它拿出来跑一遍,就能立刻分清是内核的行为还是外壳的行为。
现在能做什么:能用一个简单工具触发至少两轮推理,并从日志里按顺序指出每一次模型推理、每一次工具调用、每一次结果回灌。能做到这一步,后面的 Tool、MCP、Memory、Skill 才有讨论的基础。
还缺什么:中间消息的存储与脱敏策略没定;工具的可预期失败与不可预期失败的边界没定;不同项目之间的记忆隔离目前只是「按会话隔离」这个方向,没有形成代码约束。
下一步如何演进:把中间消息从「日志」升级为「事件」,让它可以被测试断言、被前端消费、被审计留存。这件事一旦做成,数字人就从「能跑」变成了「可控」。
开源参与点:发现可作为 Issue/PR 候选的问题——官方文档对达到 maxIters 上限后的返回语义、以及中间消息在不同阶段(推理、工具调用、工具结果)的角色标注,说明得不够直观,容易让使用者误判循环行为。该问题尚未在最小 Demo 中完成复现,也尚未比对 v2.0.3 与官方 main,亦未检索官方 Issues 与 Discussions,因此当前只能记录为候选,不作为已确认缺陷,更不构成任何已提交的 Issue 或 PR。
数字人有了核
还缺一层观
能跑不等于可控
可控才敢上线
11、小结
Q1 → 一次 reply 可能转多圈:模型推理、工具调用、结果回灌、再次推理,直到模型不再要求工具才收敛出最终回答。
Q2 → Agent 实例本身不持有这一轮 call 的进度状态,串味的根因是历史消息被共享,不是实例数量的问题。
Q3 → 循环、工具、回灌、上限属于内核;会话、鉴权、审计、协议、持久化属于外壳,分界线是「一轮 call 之内还是之外」。
状态 → 数字人已具备基于 ReActAgent 的对照实现,且能用日志还原一轮多步推理的每一步。
一条用户消息
|
v
模型推理 --- 需要工具? --- 否 ---> 最终回答
| |
| 是
| |
| v
| 执行工具
| |
| v
+---- 结果作为消息回灌
一问看签名
二问看循环
三问看状态
三答定去留
12、作业
12.1 理解题:Agent 接口和 ReActAgent 是什么关系?
参考答案:Agent 是可对话最小单元的契约,只约定「给消息、还消息」;ReActAgent 是它的一个实现,在单次调用内部跑推理与行动的循环。分不清这两者,就会把循环逻辑误当成接口约定,进而在换实现时踩坑。
12.2 实战题:在 chapter/06-react-loop 上再加一个工具,让它至少触发三轮推理。
参考答案:新增一个「查询项目最近一次修改时间」的工具,把系统提示改成要求先查配置、再查修改时间、最后组织回答。跑通后,日志里应该出现三次模型推理与两次工具结果。重点不是结果对不对,而是能不能指着日志第几行说出「这是第二圈」。
12.3 排障题:日志里只有一条 assistant 消息,但工具计数显示确实调用了两次,问题出在哪?
参考答案:大概率是只取了最终回答,没有保留中间消息通道。回到第 06 章的证据链一,先确认取数方式,再确认工具是否真的执行。顺序反了会把时间浪费在模型身上。
12.4 架构判断题:什么情况下应当绕过 ReActAgent,直接调模型?
参考答案:无工具、无多步、对确定性要求高的场景,比如纯文本改写、结构化抽取。判断标准不是「框架好不好用」,而是「这个任务需不需要模型自己决定下一步」。请打开 v2.0.3 源码中 ReActAgent.java 与 Agent.java,对照循环入口与终止条件,确认你的判断有源码依据,而不是凭感觉;同时建议顺手看一眼官方 Issues 与 Releases,确认你担心的行为在当前版本的真实状态。
题目不在文里
答案在仓库里
抄不如跑一遍
跑不如改一行
13、思考
这一式的核心冲突,其实和数字人没什么关系:我们习惯把框架当成一个「调用点」,而框架真正的价值在于它定义了一套「过程」。调用点只能看结果,过程才能被观测、被测试、被约束。
从这一式开始,数字人这个案例的性质变了。前五式我们是在给它装零件,从这一式起,我们是在理解它为什么能转。一个只会调 reply() 的人,和一个能画出循环、能指出终止条件、能说清状态在哪的人,写出来的代码可能一模一样,但系统出问题时的反应速度完全不是一个量级。
这就是「会用框架」和「能参与框架演进」的差别:前者在框架的行为符合预期时看不出区别,后者在框架的行为不符合预期时,能拿出证据、能定位、能改。
会用是终点线
会改是起跑线
读源码不神秘
差的是那份耐心

浙公网安备 33010602011771号