16|从流程到 Agent:理解 ChatModelAgent 与 ReAct
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 16 篇
关键词:ADK、ChatModelAgent、ReAct、Runner、AgentEvent、工具反馈
前置条件:已完成 11|Tool 与 ToolsNode 与 07|Graph 实战。
第 11 篇提到过一个麻烦:要让模型“先查工具、看结果、再决定下一步”,用 Graph 手写这个循环很啰嗦——需要模型节点、工具节点、分支回退,还得处理边界情况。
这类“模型反复决策”的场景,正是 ADK(Agent Development Kit)要解决的。它把 ReAct 循环封装起来,你只需要描述用什么模型、给什么工具、说什么规则。
一、Agent 与 Graph 的区别
先回到第一篇的区分,现在可以讲得更具体了:
| Graph | Agent | |
|---|---|---|
| 谁决定下一步 | 开发者(分支函数、边) | 模型 |
| 路径 | 编译期可枚举 | 运行时不可预测 |
| 交付物 | 最终结果 | 全过程事件 |
| 适合 | 固定流程 | 开放式任务 |
一个系统有聊天框,不代表它是 Agent。判据是:下一步行动由谁决定。如果是你在代码里写死的,那就是 Graph。
二、ChatModelAgent 的四个要素
官方文档给出的定义很精炼:
ChatModelAgent = 以 ChatModel 作为决策器、以 Tools 作为行动空间、以工具反馈和历史记录作为下一轮决策上下文的 ReAct Agent。
拆开看四个要素,缺一不可:
| 要素 | 作用 | 缺失的后果 |
|---|---|---|
| ChatModel(决策器) | 每轮判断下一步做什么 | 无法推理 |
| Tools(行动空间) | 定义能采取的行动 | 只能空谈,无法执行 |
| 反馈(工具结果) | 把执行结果送回上下文 | 无法根据真实结果修正 |
| 历史(累积上下文) | 保留推理轨迹与工具结果 | 每轮都从零开始 |
关键特征是累积上下文驱动的渐进式决策:每一轮模型看到的不是孤立问题,而是从开始到现在的完整求解过程。
注意:ChatModelAgent 不是一次模型调用,而是一次可持续推进的求解过程。这句话决定了它的成本模型——一次Run可能触发多次模型调用。
三、最小可用配置
agent,err:=adk.NewChatModelAgent(ctx,&adk.ChatModelAgentConfig{Name:"ticket-assistant",Description:"查询工单状态的助手",Instruction:"你是工单助手,需要时调用工具查询。",Model:chatModel,ToolsConfig:adk.ToolsConfig{ToolsNodeConfig:compose.ToolsNodeConfig{Tools:[]tool.BaseTool{ticketTool},},},})字段含义:
| 字段 | 说明 |
|---|---|
Name | Agent 名称。作为子 Agent 被调用时必填 |
Description | 能力描述,帮助其他 Agent 判断是否该转交任务 |
Instruction | 系统提示词。支持用会话变量填充占位符 |
Model | 决策模型。使用工具时,模型必须支持model.WithTools |
ToolsConfig | 工具配置,复用compose.ToolsNodeConfig |
MaxIterations默认20。超过上限时 Agent 会以 error 结束,而不是无限循环下去——这是保护机制,也是排查“为什么突然报错”时该先看的地方。
四、Runner:执行入口
构建好 Agent 后,通过 Runner 运行:
runner:=adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent,EnableStreaming:false,})iter:=runner.Query(ctx,"工单 T-1001 现在是什么状态?")for{event,ok:=iter.Next()if!ok{break}// 处理 event}只有经过 Runner,才能获得事件流、检查点与中断恢复等运行期能力。直接调用 Agent 的Run拿不到这些(更准确地说,Run也返回AsyncIterator,但EnableStreaming、CheckPointStore等运行配置由 Runner 统一管理)。
Query是Run的便捷方法:把单个字符串包装成用户消息。Run接受完整的消息列表。
五、完整示例:看清 ReAct 循环
下面这段程序用一个模拟模型跑通完整的“模型 → 工具 → 模型”循环,不访问网络:
packagemainimport("context""fmt""log""github.com/cloudwego/eino/adk""github.com/cloudwego/eino/components/model""github.com/cloudwego/eino/components/tool""github.com/cloudwego/eino/components/tool/utils""github.com/cloudwego/eino/compose""github.com/cloudwego/eino/schema")typeTicketReqstruct{TicketIDstring`json:"ticket_id" jsonschema:"description=工单编号"`}// mockModel 模拟两轮决策:先请求调用工具,看到工具结果后给出最终回答。typemockModelstruct{}func(m*mockModel)Generate(_context.Context,in[]*schema.Message,_...model.Option)(*schema.Message,error){for_,msg:=rangein{ifmsg.Role==schema.Tool{returnschema.AssistantMessage("工单 T-1001 已分派给网络支持组。",nil),nil}}returnschema.AssistantMessage("",[]schema.ToolCall{{ID:"call_1",Type:"function",Function:schema.FunctionCall{Name:"get_ticket",Arguments:`{"ticket_id":"T-1001"}`,},}}),nil}func(m*mockModel)Stream(ctx context.Context,in[]*schema.Message,opts...model.Option)(*schema.StreamReader[*schema.Message],error){msg,err:=m.Generate(ctx,in,opts...)iferr!=nil{returnnil,err}sr,sw:=schema.Pipe[*schema.Message](1)gofunc(){defersw.Close()sw.Send(msg,nil)}()returnsr,nil}func(m*mockModel)WithTools([]*schema.ToolInfo)(model.ToolCallingChatModel,error){returnm,nil}funcmain(){iferr:=run();err!=nil{log.Fatal(err)}}funcrun()error{ctx:=context.Background()ticketTool,err:=utils.InferTool[*TicketReq,string]("get_ticket","查询工单状态",func(_context.Context,req*TicketReq)(string,error){return"工单 "+req.TicketID+" 状态:已分派,处理人:网络支持组",nil})iferr!=nil{returnerr}agent,err:=adk.NewChatModelAgent(ctx,&adk.ChatModelAgentConfig{Name:"ticket-assistant",Description:"查询工单状态的助手",Instruction:"你是工单助手,需要时调用工具查询。",Model:&mockModel{},ToolsConfig:adk.ToolsConfig{ToolsNodeConfig:compose.ToolsNodeConfig{Tools:[]tool.BaseTool{ticketTool},},},})iferr!=nil{returnfmt.Errorf("创建 Agent 失败: %w",err)}runner:=adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent,EnableStreaming:false})iter:=runner.Query(ctx,"工单 T-1001 现在是什么状态?")n:=0for{event,ok:=iter.Next()if!ok{break}n++ifevent.Err!=nil{fmt.Printf("event %d: ERR=%v\n",n,event.Err)continue}ifmv:=event.Output.MessageOutput;mv!=nil&&mv.Message!=nil{// mv.Role 表示这条消息来自模型还是工具;mv.ToolName 在工具结果时非空。fmt.Printf("event %d: role=%-9s tool=%-10s content=%q tool_calls=%d\n",n,mv.Role,mv.ToolName,mv.Message.Content,len(mv.Message.ToolCalls))}ifevent.Action!=nil&&event.Action.Exit{fmt.Printf("event %d: 动作=退出\n",n)}}fmt.Println("事件总数:",n)returnnil}实机输出:
event 1: role=assistant tool= content="" tool_calls=1 event 2: role=tool tool=get_ticket content="工单 T-1001 状态:已分派,处理人:网络支持组" tool_calls=0 event 3: role=assistant tool= content="工单 T-1001 已分派给网络支持组。" tool_calls=0 事件总数: 3这三行就是 ReAct 循环的完整形状:
- 事件 1:模型输出为空、
tool_calls=1——它没有直接回答,而是请求调用get_ticket。 - 事件 2:角色是
tool,tool_name是get_ticket——工具执行结果回到流程。 - 事件 3:模型基于工具结果给出最终回答,没有新的工具调用,循环结束。
对照第 11 篇手写 Graph 的版本可以看出,这套循环现在由框架管理:模型决策、工具执行、结果回填、再次决策,都不需要你写分支逻辑。你要做的只是定义工具和规则。
六、AgentEvent 里有什么
每次iter.Next()拿到一个AgentEvent:
| 字段 | 含义 |
|---|---|
AgentName | 产生事件的 Agent 名称 |
Output.MessageOutput | 消息输出(模型输出或工具结果) |
Action | 动作,如退出、中断、转交 |
Err | 错误 |
RunPath | 从根 Agent 到当前事件的执行路径 |
MessageOutput上有几个字段很关键:
| 字段 | 用法 |
|---|---|
Message | 非流式时的完整消息 |
MessageStream | 流式时的读取器 |
Role | 这条消息来自模型(assistant)还是工具(tool) |
ToolName | 工具结果时非空,流式下这是识别来源的唯一方式 |
务必处理event.Err。事件流可能在中途携带错误,忽略它会让失败看起来像“正常结束但没输出”。
七、关于事件流的两个常见误解
误解一:事件流等于模型的完整思维链。
不是。事件反映的是执行步骤——模型输出、工具调用、工具结果、动作。模型内部如何得出这个结果(内部的推理 token),事件流并不展示。支持深度思考的模型会把推理内容放在消息的ReasoningContent字段,那是另一回事。
误解二:事件流越长说明 Agent 越聪明。
恰恰相反。事件多意味着多轮往返,直接对应更多的模型调用、更长的耗时和更高的成本。事件数量是成本指标,不是能力指标。
八、什么时候该用 Agent
| 场景 | 建议 |
|---|---|
| 步骤固定、可提前枚举 | Graph / Chain |
| 步骤依赖运行时结果,无法提前写死 | ChatModelAgent |
| 需要多轮试错、根据反馈修正 | ChatModelAgent |
| 单次生成、无需外部调用 | 直接用 ChatModel |
判断标准很简单:如果你能用一段流程图把步骤画完,就不需要 Agent。只有当“下一步做什么”真的取决于运行结果时,自主决策才带来价值。
反过来说,用了 Agent 并不意味着放弃控制:
- 关键业务操作仍应通过工具内部鉴权(第 11 篇)。
- 不可逆操作可以要求人工确认(第 20 篇)。
- 迭代次数有上限(
MaxIterations),不会无限跑下去。
九、常见问题
Agent 不调用工具,直接回答
检查Instruction是否说明了工具的使用时机,以及模型是否支持WithTools。工具描述含糊时(第 11 篇),模型难以判断何时使用。
报错说迭代次数超限
默认上限 20。可能是模型陷入循环,也可能是工具持续返回让模型“还想再查”的结果。先用事件流看清发生了什么,再决定是放宽上限还是修正工具。
event.Output为空指针
不同事件类型携带的内容不同。访问前先判空,特别是Output、MessageOutput和Message三层。
流式下拿不到内容
流式事件的内容在MessageStream里,需要Recv读取。另外要先SetAutomaticClose或自行Close,避免泄漏。
同样的问题,每次工具调用次数不同
这是自主决策的正常表现。用事件流观察典型路径,并关注成本上限。
想让 Agent 记住上一次对话
Agent 自身不维护跨请求记忆。需要把历史消息传给Run,或使用会话机制(第 18 篇)。
十、小结
- Agent 与 Graph 的分界在于下一步由谁决定:模型还是开发者。
ChatModelAgent四要素:决策器、行动空间、反馈、累积上下文,缺一不可。- 它是一次可持续推进的求解过程,一次运行可能触发多次模型调用。
Runner是执行入口,只有经过它才能获得事件流与运行期能力。- 事件流展示的是执行步骤,不等于模型的完整思维链。
MaxIterations默认 20,是防失控的保护,不是可忽略的配置。
下一篇《Runner 与事件流:看见 Agent 的执行过程》,我们系统地消费事件,为交互界面和诊断提供依据。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- 五分钟上手 Eino ADK
- Eino ADK 概述
- Agent 还是 Graph?AI 应用路线辨析
- Eino 主仓库
本文示例按Eino v0.9.21编写,使用模拟模型(不访问网络),已实机运行,正文事件流为真实输出。