前面我们已经完整学习了整套工具调用的底层组件:
- 1.
ToolInfo:给大模型阅读的工具说明书; - 2.
InvokableTool:把Go业务函数封装成可执行工具; - 3.
ToolsNode:工具调度执行器,接收ToolCall,自动匹配、执行工具、封装ToolMessage。
📌重要:第十四篇的Demo,只演示了单次调用ToolsNode,没有写ReAct循环。
如果业务直接裸用ChatModel + ToolsNode,业务代码需要自己手写for循环实现ReAct思考‑行动循环,自己处理最大迭代次数、上下文拼接、循环退出判断,手写循环容易引入bug。
Eino ADK提供了 ChatModelAgent,它把完整的ReAct思考‑行动‑观测循环全部封装在组件内部。交给Agent,业务侧只需要传入原始对话消息,由Agent内部自动完成:模型思考、判断要不要调用工具、执行工具、回填结果、反复迭代,直到模型输出最终答案。
💡重要概念区分
- 1.
ChatModel:基础大模型组件,只做单次请求,没有循环逻辑; - 2.
ToolsNode:单纯工具执行器,只管执行工具,不会调用大模型,也没有循环; - 3.
ChatModelAgent:高层智能体,内部组合 ChatModel + ToolsNode,内部自带ReAct循环,业务代码不用手写for循环。
通俗比喻:
- • ChatModel = 只会单次回答问题的人脑;
- • ToolsNode = 工具箱操作台;
- • ChatModelAgent = 一个完整的助手,会自己循环思考:要不要拿工具干活、干完活再接着思考,直到把问题解决。
底层真相:
ChatModelAgent底层基于compose.Graph编排实现,内部会复用我们学过的ToolsNodeConfig全部配置。
一、ReAct循环原理(ChatModelAgent核心逻辑)
ReAct = Reason(思考) → Action(行动) → Observation(观测)
完整循环流程:
- 1. Reason 思考:Agent把上下文送入ChatModel,模型思考,输出消息;
- 2. 判断模型返回消息是否携带
ToolCall;- • ✅ 如果有ToolCall:进入Action环节;
- • ❌ 如果没有ToolCall:代表模型已经得到最终答案,循环直接结束,返回结果;
- 3. Action 行动:把携带ToolCall的消息交给内部ToolsNode,执行对应工具;
- 4. Observation 观测:把工具返回的
ToolMessage回填对话上下文; - 5. 回到第一步,再次调用模型,继续下一轮循环。
安全保护:配置
MaxIterations最大迭代次数,防止无限死循环。
二、ChatModelAgentConfig 核心配置项
包路径:github.com/cloudwego/eino/adk
ChatModelAgentConfig是创建Agent的配置结构体,核心字段:
| 配置项 | 作用 |
|---|---|
Name | Agent名称,如果该Agent被包装成子Agent工具时必填 |
Instruction | Agent的系统提示词,会自动拼接到上下文,替代手动写schema.SystemMessage |
Model | 必填,传入model.ToolCallingChatModel,具备工具调用能力的模型实例 |
ToolsConfig | 工具配置,复用compose.ToolsNodeConfig,注册所有InvokableTool,UnknownToolsHandler、中间件、串行/并行全部沿用之前ToolsNode的能力 |
MaxIterations | 最大迭代次数,防止死循环,默认20次 |
Handlers | Agent事件处理器,可以拦截每一轮思考、工具执行事件,做日志、埋点、鉴权 |
注意:
ToolsConfig完全复用ToolsNodeConfig,我们上一篇学的UnknownToolsHandler、ExecuteSequentially、ToolCallMiddlewares在这里全部生效,不需要重新学习。
三、完整可运行Demo
复用前面课程的两个业务工具:邮编查询、天气查询。
Agent内部自动跑ReAct循环,业务代码不再手写for循环。
main.go 完整代码
package main
import (
"context"
"fmt"
"os"
"strings"
eino_openai "github.com/cloudwego/eino-ext/components/model/openai"
"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"
)
// ==================== Eino ADK Agent + 工具 完整 ReAct 闭环 Demo ====================
//
// 本 demo 面向新手,展示 Eino 框架中最高层的 Agent 使用方式:
// 1. 定义工具(邮编查询 + 天气查询,用 InferTool 自动推导 ToolInfo)
// 2. 创建 ChatModel(对接 DeepSeek 大模型)
// 3. 用 adk.NewChatModelAgent 组装出带工具的 Agent
// 4. 用 adk.NewRunner + runner.Query 启动执行,看到完整 ReAct 闭环:
// 用户提问 → LLM 选工具 → ToolsNode 执行工具 → 结果回传 LLM → LLM 总结回答
//
// 与前面 篇章 对比:
// - learn05:纯手写 ReAct(用 openai SDK,最底层)
// - learn12/14:只测 ToolsNode(手动构造 ToolCall,不接 LLM)
// - 本 demo:用 Eino ADK,一行配置搞定 LLM+Tools+循环,最接近工程实战
const (
baseURL = "https://api.deepseek.com"
modelName = "deepseek-v4-flash" // DeepSeek 默认模型,稳定兼容 ToolCall
)
// ptr 返回值的指针,用于 *float32 等需要指针的配置字段。
func ptr[T any](v T) *T { return &v }
// ==================== 第一部分:定义工具(InferTool 自动推导) ====================
// ZipParam 邮编查询入参。
type ZipParam struct {
City string `json:"city" jsonschema:"description=待查询的国内省会城市名称,如北京、上海、广州,required=true"`
}
// WeatherParam 天气查询入参(enum 限定可选值,避免 LLM 幻觉出不支持的城市)。
type WeatherParam struct {
City string `json:"city" jsonschema:"description=待查询的国内城市名称,enum=北京,enum=上海,enum=广州,required=true"`
}
// getZipcode 邮编查询(模拟数据,实际项目可对接真实 API)。
func getZipcode(_ context.Context, p *ZipParam) (string, error) {
zipData := map[string]string{
"北京": "100000",
"上海": "200000",
"广州": "510000",
}
if zip, ok := zipData[p.City]; ok {
return fmt.Sprintf("%s 的邮政编码:%s", p.City, zip), nil
}
return fmt.Sprintf("暂无 %s 的邮编数据", p.City), nil
}
// getWeather 天气查询(模拟数据)。
func getWeather(_ context.Context, p *WeatherParam) (string, error) {
weatherData := map[string]string{
"北京": "26℃ 晴,微风",
"上海": "28℃ 多云",
"广州": "32℃ 雷阵雨",
}
if w, ok := weatherData[p.City]; ok {
return fmt.Sprintf("%s 今日天气:%s", p.City, w), nil
}
return fmt.Sprintf("暂无 %s 的天气数据", p.City), nil
}
// buildZipTool 构造邮编工具。
func buildZipTool() (tool.InvokableTool, error) {
return utils.InferTool(
"get_city_zipcode",
"查询国内省会城市的邮政编码",
getZipcode,
)
}
// buildWeatherTool 构造天气工具。
func buildWeatherTool() (tool.InvokableTool, error) {
return utils.InferTool(
"get_city_weather",
"查询国内城市当日天气,仅支持北京/上海/广州三个城市",
getWeather,
)
}
// ==================== 第二部分:创建 ChatModel + Agent ====================
// newChatModel 创建 DeepSeek ChatModel,BaseChatModel 是 BaseModel[*schema.Message] 的别名。
func newChatModel(ctx context.Context) (model.BaseChatModel, error) {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("请先设置环境变量 DEEPSEEK_API_KEY")
}
return eino_openai.NewChatModel(ctx, &eino_openai.ChatModelConfig{
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
Temperature: ptr(float32(0.3)), // 低温度,工具调用更稳定
})
}
// newAgent 创建带工具的 ChatModelAgent。
//
// Agent 内部自动完成 ReAct 循环:
//
// Step1: 把 User 消息 + 工具清单发给 LLM
// Step2: 如果 LLM 返回 ToolCall,交给 ToolsNode 执行
// Step3: 把工具结果追加到消息列表,再次调用 LLM
// Step4: 直到 LLM 给出自然语言最终回答,或达到 MaxIterations 上限
func newAgent(ctx context.Context) (*adk.ChatModelAgent, error) {
// 1. 创建底层 LLM
chatModel, err := newChatModel(ctx)
if err != nil {
return nil, fmt.Errorf("创建模型失败:%w", err)
}
// 2. 创建工具
zipTool, err := buildZipTool()
if err != nil {
return nil, fmt.Errorf("创建邮编工具失败:%w", err)
}
weatherTool, err := buildWeatherTool()
if err != nil {
return nil, fmt.Errorf("创建天气工具失败:%w", err)
}
// 3. 组装 Agent(核心一行配置)
return adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "city_helper",
Description: "查询城市天气和邮编的生活助手",
Instruction: "你是城市信息助手。根据用户问题,优先调用工具查询真实数据," +
"然后用自然语言整理结果给用户。禁止编造数据。",
Model: chatModel,
// ToolsConfig 嵌入 compose.ToolsNodeConfig,复用 ToolsNode 的全套能力
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{zipTool, weatherTool}, // 注册工具列表
// 工具幻觉兜底:LLM 编造了不存在的工具名时,友好返回错误
UnknownToolsHandler: func(_ context.Context, name, _ string) (string, error) {
return fmt.Sprintf("工具【%s】不存在,请确认后重试", name), nil
},
ExecuteSequentially: false, // 多个 ToolCall 并行执行(更快)
},
},
MaxIterations: 5, // ReAct 循环上限,防止无限循环
})
}
// ==================== 第三部分:执行辅助(新手友好:打印事件过程) ====================
// runAndAnswer 用 Runner 执行用户问题,遍历事件流,返回最终答案。
// 同时把中间事件(工具调用、工具结果)打印出来,方便新手理解 ReAct 流程。
func runAndAnswer(ctx context.Context, r *adk.Runner, question string) (string, error) {
fmt.Printf(">>> 用户:%s\n", question)
iter := r.Query(ctx, question) // Runner 自动把 string 转成 user Message
var finalAnswer string
for {
event, ok := iter.Next() // 取下一个事件
if !ok {
break // 事件流结束
}
if event.Err != nil {
return "", fmt.Errorf("执行出错:%w", event.Err)
}
// 无输出的心跳事件直接跳过
if event.Output == nil || event.Output.MessageOutput == nil {
continue
}
msgVar := event.Output.MessageOutput // TypedMessageVariant[*schema.Message]
// 根据 Role 判断事件类型:工具结果 / 模型输出
switch msgVar.Role {
case schema.Tool:
// 工具执行结果事件:打印工具名和返回内容
msg, err := msgVar.GetMessage()
if err != nil {
return "", fmt.Errorf("读取工具结果失败:%w", err)
}
toolName := msgVar.ToolName
if toolName == "" {
toolName = "unknown-tool"
}
content := strings.TrimSpace(msg.Content)
if len(content) > 80 {
content = content[:80] + "..."
}
fmt.Printf(" [工具] %s → %s\n", toolName, content)
case schema.Assistant:
// 模型输出事件:非流式下就是最终回答
msg, err := msgVar.GetMessage()
if err != nil {
return "", fmt.Errorf("读取模型输出失败:%w", err)
}
// 有 ToolCall 说明模型正在决定调用工具,还没到最终回答
if len(msg.ToolCalls) > 0 {
names := make([]string, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
names = append(names, tc.Function.Name)
}
fmt.Printf(" [模型] 决定调用工具:%s\n", strings.Join(names, "、"))
} else if msg.Content != "" {
// 没有 ToolCall + 有 Content = 最终回答
finalAnswer = msg.Content
}
}
}
if finalAnswer == "" {
return "", fmt.Errorf("Agent 未返回最终回答,请检查模型或 MaxIterations 设置")
}
return finalAnswer, nil
}
// ==================== 第四部分:主函数(两个典型案例) ====================
func main() {
ctx := context.Background()
// 1. 一次性创建 Agent + Runner
agent, err := newAgent(ctx)
if err != nil {
fmt.Printf("❌ 创建 Agent 失败:%v\n", err)
return
}
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
// 2. 案例1:单工具调用(只需要查天气)
fmt.Println("========== 案例1:查询上海天气 ==========")
ans1, err := runAndAnswer(ctx, runner, "上海今天天气怎么样?")
if err != nil {
fmt.Printf("❌ 失败:%v\n\n", err)
} else {
fmt.Printf("🤖 Agent:%s\n\n", ans1)
}
// 3. 案例2:多工具并行调用(同时要天气 + 邮编)
// Agent 会让 LLM 一次输出 2 个 ToolCall,ToolsNode 并行执行后回传。
fmt.Println("========== 案例2:北京天气 + 邮编 ==========")
ans2, err := runAndAnswer(ctx, runner, "帮我查一下北京今天的天气和邮政编码")
if err != nil {
fmt.Printf("❌ 失败:%v\n", err)
} else {
fmt.Printf("🤖 Agent:%s\n", ans2)
}
}预期输出示例
========== 案例1:查询上海天气 ==========
>>> 用户:上海今天天气怎么样?
[模型] 决定调用工具:get_city_weather
[工具] get_city_weather → 上海 今日天气:28℃ 多云
🤖 Agent:上海今天天气情况如下:
🌤 **天气状况**:多云
🌡 **气温**:28℃
今天上海以多云天气为主,气温较为舒适。不过多云天气下紫外线仍可能较强,外出建议做好防晒措施,并留意天气变化,以防午后有阵雨。祝您一天好心情!
========== 案例2:北京天气 + 邮编 ==========
>>> 用户:帮我查一下北京今天的天气和邮政编码
[模型] 决定调用工具:get_city_weather、get_city_zipcode
[工具] get_city_weather → 北京 今日天气:26℃ 晴,微风
[工具] get_city_zipcode → 北京 的邮政编码:100000
🤖 Agent:根据查询结果,北京今天的情况如下:
- **今日天气**:26℃,晴,微风 ☀️
- **邮政编码**:100000
如需查询其他城市,随时告诉我!内部发生的事情(业务代码看不到,Agent内部自动完成)
- 1. 模型第一次思考,产生ToolCall;
- 2. 内部ToolsNode并行执行天气、邮编两个工具;
- 3. 工具结果回填上下文;
- 4. 再次送入模型,模型不再生成ToolCall,输出自然语言总结答案;
- 5. Agent把最终assistant消息返回给我们。
四、关键知识点解读
- 1. Instruction替代SystemMessage
ChatModelAgentConfig.Instruction就是Agent内置的system提示词,不需要我们手动构造schema.SystemMessage,Agent内部自动处理。 - 2. ToolsConfig就是ToolsNodeConfig
之前学ToolsNode所有配置项,UnknownToolsHandler、ExecuteSequentially、中间件全部原样生效,降低学习成本。Agent内部会实例化ToolsNode用于工具执行。 - 3. MaxIterations最大迭代次数
这是安全防护,防止模型陷入无限工具调用死循环。达到最大次数后Agent直接终止执行返回错误。 - 4. 对外接口
Run()
输入:[]*schema.Message原始对话上下文;
输出:*schema.Message,模型最终的assistant回答,已经把所有ReAct内部循环全部封装,使用者看不到中间ToolCall、ToolMessage。
💡补充:Agent还支持
StreamRun()流式接口,可以拿到逐步输出的回答。
五、新手避坑清单
❌ Model必须传入实现model.ToolCallingChatModel接口的实例,普通BaseChatModel不能用于Agent;
❌ 忘记设置MaxIterations,极端情况下模型不停调用工具,进入死循环;
❌ 不要自己在业务层写for循环反复调用agent.Run()!Agent内部已经自带ReAct循环;
❌ ToolsConfig里面注册工具名字和ToolInfo.Name不一致,触发UnknownToolsHandler;
❌ Instruction不要写工具调用逻辑,工具能力全部靠注册InvokableTool,不要让模型用文本模拟调用工具。
💡调试小技巧:开发调试阶段,可以开启Agent的
Handlers事件处理器,打印每一轮思考、工具调用事件,观察内部ReAct每一步发生了什么。
六、组件知识串联总结
回顾整套工具调用链路:
- 1.
ToolInfo:工具说明书; - 2.
InvokableTool:Go函数封装成可执行工具; - 3.
ToolsNode:接收ToolCall消息,调度执行工具;裸用该组件业务层要手写for循环实现ReAct; - 4.
ChatModelAgent:高层封装,内部组合ChatModel+ToolsNode,内部自带ReAct循环,业务代码无需手写循环,只调用Run()即可拿到最终结果。
组件层级:
业务代码 → ChatModelAgent(ADK高层) → ChatModel / ToolsNode(编排组件) → InvokableTool → ToolInfo
理解:组件是逐层封装,底层的每一个组件我们前面课程全部拆解学习过。
--
MiaoAll