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:484NewChatModelAgent)。看懂它 = 看懂"图引擎如何撑起一个 Agent"。
ReAct 是啥?(就像生活中的实习生干活) Reason + Act 就像生活中带一个实习生:他先想"这活要啥信息"→ 动手去查/去做(Act)→ 看看结果对不对(Observe)→ 再想下一步 → 直到交出成果。模型先想"我需要什么信息"→ 调用工具去拿 → 看工具结果 → 再想 → 直到能回答。ChatModelAgent 把这个"实习生循环"自动化了,你只管给他"脑子"(模型)和"工具箱"(工具)。一句话复述:ReAct = 想一步、动手一下、看结果、再想,循环到能收工。
① 想(Reason) 模型决定要不要调工具 ② 调工具(Act) ToolsNode 真的执行 ③ 看结果(Observe) 结果追加进消息历史 ✅ 无 tool_calls → END 输出最终答案
ReAct 循环:想 → 调工具 → 看结果 → 回头再想;一旦模型输出里不再有 tool_calls,就走左侧虚线出 END。这正是 L03 那张图的行为。
L02

配置项(真实代码)

adk/chatmodel.goChatModelAgentConfig

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——双层防护。
📝 举例:三步就跑起一个天气助手 ① 准备 configModel=某个支持工具调用的模型、ToolsConfig 里放一个 get_weather 工具、Instruction="你是天气助手"。② agent, _ := adk.NewChatModelAgent(ctx, config)chatmodel.go:484)——此刻内部那张图已经搭好。③ 用 Day 11 的 Runner.Query(ctx, "北京天气?") 驱动它跑,收事件流。你写的只有"配置",循环逻辑一行都没碰。
L03

内部那张 Graph

NewChatModelAgent 内部搭的图,正是 Day 08 讲的"回头边+分支"循环:

STARTChatModel 节点
分支:输出里有 tool_calls 吗?
有 ↓     没有 → END(最终答案)
ToolsNode 节点(执行工具)
↺ 回头边:工具结果追加进消息,回到 ChatModel
和 Day 08 L07 的拓扑一模一样AddEdge(START,"model") + AddBranch("model", 有工具→"tools",无→END) + AddEdge("tools","model")。这就是 Agent 循环的真身。因为用 Graph(Pregel 引擎),回头边才被允许(Day 09)。

拿一个具体输入"北京今天多少度?"按单步走查表跟着这张图跑一遍,看每一步走到哪个节点、分支怎么判:

当前节点此刻发生什么下一步去哪
1START → ChatModel模型看到问题,输出里带 tool_calls=[get_weather(北京)]进分支判断
2分支看流的第一个 chunk,发现有 tool_calls→ ToolsNode
3ToolsNode真的执行 get_weather("北京"),得"晴 25℃",包成 Tool 消息回头边 → ChatModel
4ChatModel看到工具结果,这次输出 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 官方为什么推荐前者。
← Day 11 ADK Day 13 · 多智能体 →