Day 12 / 共 20 天 · 第 3 周 ADK 智能体开发
ChatModelAgent 剖析
最常用的 Agent。今天拆开它,看内部那张"模型 ↔ 工具"循环 Graph 的真实搭建代码,把第 2 周的引擎知识和 Agent 彻底打通。
📍 你在整门课的位置 · 第 3 周 Agent 套件(共 4 周 · 20 天)
D11 ADK 抽象→
D12 ChatModelAgent→
D13 多智能体→
D14 中断恢复→
D15 ReAct 精读
L01
ChatModelAgent 是什么
它是"一个会用工具的对话 Agent"——给它一个 ChatModel + 一组工具,它就能自动完成 ReAct 循环(推理→行动→观察→再推理),直到得出最终答案。Day 02 你建的就是它。
🤔 痛点:模型自己不会执行代码,怎么让它"用上"工具?
大模型只会输出文字,它没法真去查数据库、调 API。要让它"会用工具",你得:把工具清单告诉模型 → 解析它输出里的"我想调 X 工具"→ 真的执行 X → 把结果塞回去让它接着想 → 循环。这套手写起来又琐碎又易错。ChatModelAgent 把整条流水线封装成一张图,你只交模型和工具。
💡 本质:把"想 → 调工具 → 看结果 → 再想"的循环封装成一张 compose 图
ChatModelAgent 不是什么新引擎——它就是用第 2 周的
compose.Graph(回头边 + 流式分支)把 ReAct 循环搭出来,再用 Run 把图执行翻译成事件流(adk/chatmodel.go:484 的 NewChatModelAgent)。看懂它 = 看懂"图引擎如何撑起一个 Agent"。ReAct 是啥?(就像生活中的实习生干活)
Reason + Act 就像生活中带一个实习生:他先想"这活要啥信息"→ 动手去查/去做(Act)→ 看看结果对不对(Observe)→ 再想下一步 → 直到交出成果。模型先想"我需要什么信息"→ 调用工具去拿 → 看工具结果 → 再想 → 直到能回答。ChatModelAgent 把这个"实习生循环"自动化了,你只管给他"脑子"(模型)和"工具箱"(工具)。一句话复述:ReAct = 想一步、动手一下、看结果、再想,循环到能收工。
ReAct 循环:想 → 调工具 → 看结果 → 回头再想;一旦模型输出里不再有 tool_calls,就走左侧虚线出 END。这正是 L03 那张图的行为。
L02
配置项(真实代码)
adk/chatmodel.go 的 ChatModelAgentConfig:
type ChatModelAgentConfig struct {
Name string
Description string
Instruction string // 系统提示词(角色设定)
Model model.ToolCallingChatModel // 会调工具的模型(Day 04)
ToolsConfig ToolsConfig // 可用工具集
MaxIterations int // 最多循环几轮(默认 20,防死循环)
// ... GenModelInput / OutputKey 等
}
agent, _ := adk.NewChatModelAgent(ctx, &cfg) // chatmodel.go:484
读法:核心四样:Instruction(系统提示,定角色)、Model(会调工具的模型)、ToolsConfig(工具箱)、MaxIterations(循环上限)。其余可选。
NewChatModelAgent 在 chatmodel.go:484 构建,内部就开始搭那张 Graph。MaxIterations 是安全带:万一模型陷入"反复调工具却得不出答案"的死循环,到 20 轮强制停。对应 Day 09 图层的
MaxRunSteps——双层防护。📝 举例:三步就跑起一个天气助手
① 准备
config:Model=某个支持工具调用的模型、ToolsConfig 里放一个 get_weather 工具、Instruction="你是天气助手"。② agent, _ := adk.NewChatModelAgent(ctx, config)(chatmodel.go:484)——此刻内部那张图已经搭好。③ 用 Day 11 的 Runner.Query(ctx, "北京天气?") 驱动它跑,收事件流。你写的只有"配置",循环逻辑一行都没碰。L03
内部那张 Graph
NewChatModelAgent 内部搭的图,正是 Day 08 讲的"回头边+分支"循环:
START→ChatModel 节点
↓
分支:输出里有 tool_calls 吗?
有 ↓ 没有 → END(最终答案)
ToolsNode 节点(执行工具)
↺ 回头边:工具结果追加进消息,回到 ChatModel
和 Day 08 L07 的拓扑一模一样:
AddEdge(START,"model") + AddBranch("model", 有工具→"tools",无→END) + AddEdge("tools","model")。这就是 Agent 循环的真身。因为用 Graph(Pregel 引擎),回头边才被允许(Day 09)。拿一个具体输入"北京今天多少度?"按单步走查表跟着这张图跑一遍,看每一步走到哪个节点、分支怎么判:
| 步 | 当前节点 | 此刻发生什么 | 下一步去哪 |
|---|---|---|---|
| 1 | START → ChatModel | 模型看到问题,输出里带 tool_calls=[get_weather(北京)] | 进分支判断 |
| 2 | 分支 | 看流的第一个 chunk,发现有 tool_calls | → ToolsNode |
| 3 | ToolsNode | 真的执行 get_weather("北京"),得"晴 25℃",包成 Tool 消息 | 回头边 → ChatModel |
| 4 | ChatModel | 看到工具结果,这次输出无 tool_calls:一句完整答案 | 进分支判断 |
| 5 | 分支 | 没有 tool_calls | → END(输出"北京今天晴,25℃") |
读法:盯住"下一步去哪"这列——只要模型还在要工具,就一直"分支→工具→回头→模型"绕圈;哪一轮模型不要工具了,分支立刻把它送出 END。这道简单问题绕了 1 圈工具;复杂问题可能绕好几圈,直到
MaxIterations(默认 20)兜底。L04
模型节点:绑定工具
建模型节点前,先把工具"告诉"模型——用 Day 04 学的 WithTools:
// 概念示意(chatmodel.go 内)
modelWithTools, _ := cfg.Model.WithTools(toolInfos) // 让模型知道有哪些工具可调
g.AddChatModelNode("model", modelWithTools)
读法:
WithTools 返回一个"知道工具清单"的新模型(Day 04 讲过它返回新实例、不改原对象——不可变设计)。模型知道工具的名字/参数 schema 后,才可能在输出里产生 tool_calls。模型怎么"调"工具?(还是那个实习生的比喻)
承接 L01 的实习生比喻:这个实习生(模型)自己不跑腿,他只写一张便签"帮我查下北京天气"(即输出里的
ToolCalls,"我想调 get_weather,参数 city=北京",Day 03 见过这个字段),真正拿着便签去查、把结果贴回来的是助理(下游 ToolsNode,L06)。模型负责"决定调什么",ToolsNode 负责"真的去调"——职责分离。⚠️ 小白常误以为:大模型能自己联网、自己执行函数。其实:模型只会输出文字,那段
ToolCalls 也只是它"写"出来的一段结构化文字(意图),它碰不到你的代码。真正跑代码、访问外部世界的永远是你注册的工具函数——所以工具的安全边界完全由你掌控。L05
流式分支:模型刚开口就判断
分支用的是 Day 08 讲的流式分支(NewStreamGraphBranch)——看模型输出流的开头就能判断有没有 tool_calls,不用等整段生成完:
// 概念:条件函数吃的是流
func(ctx, stream *schema.StreamReader[*Message]) (string, error) {
msg, _ := stream.Recv() // 只看第一个 chunk
if len(msg.ToolCalls) > 0 { return "tools", nil } // 要调工具
return END, nil // 直接回答
}
读法:只 Recv 第一个 chunk 就能判断路由方向。因为模型要调工具时,tool_calls 信息在流的最开头就出现了。判断完,剩余的流交给对应节点继续处理(Day 03 的流可以边收边转)。
为什么用流式分支? 提速。如果等模型把整段回答生成完再判断"要不要调工具",用户会多等几秒。流式分支让"刚开口就分流"——要调工具立刻去调,要回答立刻开始往前端推字。这是 Day 06 流式设计在 Agent 层的直接收益。
L06
工具节点与回头边
ToolsNode(compose/tool_node.go)拿到模型输出里的 tool_calls,并发执行对应工具,把每个结果包成一条 Role=Tool 的消息,追加到消息列表,然后回头边送回模型:
// 概念:ToolsNode 内
for _, call := range msg.ToolCalls { // 可能多个工具调用
tool := 按 call.Function.Name 找到工具
result := tool.InvokableRun(ctx, call.Function.Arguments) // 真正执行
toolMsgs = append(toolMsgs, schema.ToolMessage(result, call.ID))
}
// 这些 tool 消息经回头边追加进历史,回到 ChatModel 再推理
读法:一条模型消息里可能有多个 tool_calls(并行工具),ToolsNode 全部执行(并发),每个结果变一条 Tool 消息(带 call.ID 对应上是哪个调用)。然后回到模型——模型看到工具结果,继续推理或给出最终答案。
schema.ToolMessage(content, toolCallID)(Day 03)就是在这里造出来的——toolCallID 让模型知道"这个结果对应我刚才发的哪个调用请求"。工具执行出错也会包成消息回给模型,让模型自己决定重试或换方案(而不是直接崩)——这又是"优雅降级"。L07
图执行 → 翻译成事件流
Agent 的 Run 方法(Day 11)驱动这张图执行,并把图每一步的产出翻译成 AgentEvent 往迭代器里塞:模型每说一句 → 一个 Output 事件;要调工具 → 相关事件;最终答案 → 最后一个 Output 事件。
这就是 Day 11 和 Day 12 的合流点:图在内部一轮轮转(super-step),每轮的产出被 Run 转成事件,通过
AsyncIterator 实时交给你。所以你 iter.Next() 能看到 Agent "边想边调工具边回答"的全过程。引擎(Graph)在跑,ADK(Run)在翻译,你(Runner.Query)在收——三层清晰分工。L08
今日小结 + 动手
🧠 今天你应该能回答
- ChatModelAgent 干什么?ReAct 循环是什么?
- 它内部的 Graph 拓扑长什么样?(模型→流式分支→工具→回头边)
- 模型怎么"调"工具?谁真正执行工具?
- 为什么用流式分支?
- 图执行怎么变成 AgentEvent 事件流?
✋ 动手
sed -n '1,60p' adk/chatmodel.go # Config
sed -n '484,520p' adk/chatmodel.go # NewChatModelAgent
grep -n 'AddBranch\|AddEdge\|NewStreamGraphBranch\|ToolsNode' adk/chatmodel.go
sed -n '1,80p' compose/tool_node.go | head -50 # ToolsNode 执行工具
明天预告 · Day 13:一个 Agent 不够用时怎么办?多智能体——Agent-as-Tool(把子 Agent 当工具,推荐)vs Transfer(转交,不推荐),以及 Supervisor 模式。看 Eino 官方为什么推荐前者。