Day 02 / 共 20 天 · 第 1 周 建立心智

环境搭建 & 第一个 Agent

今天动手:装 Go、拉依赖,用最小代码跑通一个 ChatModelAgent,感受"给个模型 + 工具 = 会自己干活的 Agent"。

📍 你在整门课的位置 · 第 1 周 建立心智(共 4 周 · 20 天)
D1 全景架构 D2 环境搭建 D3 schema 数据 D4 组件接口 D5 一次调用旅程
L01

今天的目标

Day 01 建立了地图。今天把环境搭起来,跑通"第一个 Agent",并建立三个直觉:Eino 的 Agent 是"吐事件流"的、react 循环是它的心跳、工具能用一个普通 Go 函数自动生成

没有环境也能学 今天所有代码我都会讲清"每行在干嘛",你光看也能理解。真机操作是加分项。
L02

装 Go 与拉依赖

Eino 需要 Go 1.18+(用了泛型)。三步:

# 1. 装 Go(若没有):https://go.dev/dl/  或 brew install go
go version                       # 确认 >= 1.18

# 2. 新建你的项目,拉 eino 核心 + eino-ext(实现)
mkdir my-eino-app && cd my-eino-app && go mod init my-eino-app
go get github.com/cloudwego/eino
go get github.com/cloudwego/eino-ext/components/model/openai   # 某个模型实现

# 3. 配 API key(走 OpenAI/兼容接口)
export OPENAI_API_KEY=sk-...
go get 是什么? Go 的包管理命令,把某个库下载并记进 go.mod(依赖清单)。为什么要拉两个仓? 回忆 Day 01——eino(核心)只有接口,真正能调 OpenAI 的实现eino-ext。所以要拉 eino + 你需要的那个 eino-ext/components/model/xxx
L03

三仓关系(再强调一次)

仓库装什么你会 import 什么
eino(本教程主角)接口 + 编排引擎 + Agent 套件composeadkschemacomponents/*(接口)
eino-ext组件实现 + 回调 handler + 调试工具eino-ext/components/model/openai
eino-examples示例代码(参考用,不 import)
本仓 examples/ 是空的——示例都在独立的 eino-examples 仓。所以你在本仓找不到"跑起来的例子"很正常。本教程会把关键用法直接写给你看。
L04

最小 Agent 五步(真实 API)

🤔 痛点:不用 Eino,自己拿 Go 拼一个"会查天气的助手"要写多少胶水? 你得:手写 HTTP 拼 OpenAI 的 JSON、把"工具定义"手动塞进请求、解析模型返回里的 tool_calls、自己 json.Unmarshal 出参数、调你的天气函数、把结果拼成一条 tool 消息、再把整个消息列表塞回去问第二遍……而且模型可能要连调好几轮工具,这套"解析→执行→回填→再问"的循环要你自己写。换个模型还得重来。
💡 本质:Eino 把"调模型 + 解析工具调用 + 执行 + 回填 + 循环"打包成一个 Agent 你只提供两样东西——一个 ChatModel(大脑)+ 一组 Tool(手脚),Eino 的 ChatModelAgent 就替你把那套 ReAct 循环跑起来。你的代码从"写一大坨请求/解析/循环"缩成"造 Agent → Query → 收事件"。下面这五步就是全部。

用 ADK 搭一个最小 Agent,就五步(对照 adk/runner.go + adk/chatmodel.go 的真实 API):

// 1. 造一个 ChatModel(实现来自 eino-ext)
cm, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{Model: "gpt-4o", APIKey: key})

// 2. 造一个 ChatModelAgent(adk/chatmodel.go:484 NewChatModelAgent)
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "assistant",
    Description: "一个有用的助手",
    Instruction: "You are a helpful assistant.",
    Model:       cm,
    ToolsConfig: adk.ToolsConfig{ /* Tools: []tool.BaseTool{天气工具...} */ },
    MaxIterations: 20,          // react 循环最多转 20 圈(默认 20,react.go:345)
})

// 3. 造一个 Runner(adk/runner.go:89 NewRunner)
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent, EnableStreaming: true})

// 4. 跑!Query 是便捷入口(runner.go:108),返回一个"事件迭代器"
iter := runner.Query(ctx, "北京今天天气怎么样?")

// 5. 消费事件流(关键:Agent 是"吐事件流"的,不是"返回一个值")
for {
    event, ok := iter.Next()
    if !ok { break }                          // 迭代结束
    if event.Err != nil { /* 处理错误 */ }
    if event.Output != nil && event.Output.MessageOutput != nil {
        msg, _ := event.Output.MessageOutput.GetMessage()  // 流式会自动拼成完整消息
        fmt.Println(msg.Content)
    }
}
读法:造模型 → 用模型+工具造 Agent → 用 Agent 造 Runner → Query 开跑 → for 循环消费事件。注意第 5 步不是"拿一个返回值",而是 for iter.Next() 一个个接事件——这是 Eino Agent 的核心用法。
📝 最小例子:输入 → 输出 输入(你写的一行):iter := runner.Query(ctx, "北京今天天气怎么样?")
Agent 内部自动发生:模型判断"要查天气" → 自动调 get_weather("北京") → 拿到 "晴,25℃" → 把结果回填再问模型 → 模型给出人话。
输出(你 for 循环里收到的最后一条消息):msg.Content == "北京今天晴,气温 25℃,适合出行。"
你没写任何"解析 tool_calls / 回填 / 再问一遍"的代码——那一整圈都被 Agent 包了。
① go get 装 eino + eino-ext ② 建 ChatModel openai.NewChatModel ③ 建 Agent NewChatModelAgent ④ Query 开跑 Runner → 事件流 跑通第一个 Agent 的四步
装依赖 → 建大脑(ChatModel)→ 组装成 Agent → Query 开跑收事件。②的实现来自 eino-ext,③④的构造器在本仓 adk/。
L05

关键直觉:Agent 是"吐事件流"的

adk/interface.go:453 的 Agent 接口定义——Run 不返回结果,而是返回一个异步事件迭代器

type TypedAgent[M MessageType] interface {
    Name(ctx) string
    Description(ctx) string
    Run(ctx, input *TypedAgentInput[M], opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[M]]
}
type Agent = TypedAgent[*schema.Message]   // interface.go:467 最常用的别名
为什么 Agent 只有 3 个方法、还返回迭代器?Name/Description/Run 三件——Description 尤其重要:当这个 Agent 被当作工具或子 Agent 时,Description 就是给"上级模型"看的"你能干嘛"的说明(Day 13 讲)。② Run 返回 AsyncIterator[AgentEvent]——因为 Agent 干活是流式、多步的:它会一边想一边调工具,每一步产出一个"事件"(模型说了话/调了工具/要中断/完成了),你用 for iter.Next() 实时消费。这跟 claude-code 教程里"agent 循环边跑边 yield 消息"是同一种流式思想。

事件结构 AgentEventinterface.go:419)含:AgentNameOutput(消息,可能是流)、Action(动作:Exit/Transfer/Interrupt/BreakLoop)、ErrAction 就是 Agent 用来“控制流程”的信号(Day 13/14 会用到)。

💡 生活类比:Agent 吐事件流,就像实习生边干边汇报 你交给实习生一个任务,他不会闷头几小时后突然甩给你结果,而是一边干一边冒泡:「我先查下资料」(一个事件)→「查到了,正在整理」(一个事件)→「初稿好了您看看」(一个事件)。Run 返回的迭代器就是这条"汇报流",你 for iter.Next() 一条条接。本教程 Day02 的世界观:Agent = 一个会自己想、自己动手、边干边汇报的实习生。
🧍 第一人称·请求之旅:现在你是一次 Query 跟着走一遍,你就是那句「北京今天天气怎么样?」:
我被 runner.Query 接住,包成一条 User Message 塞进消息列表。
我被送到大脑 ChatModel,它看了看我,说「这得查工具」,于是产出一条带 ToolCalls 的 Assistant Message——这是我人生第一个事件,被 iter.Next() 收走。
工具节点执行 get_weather("北京"),拿到「晴 25℃」,包成 Tool Message 追加进列表。
我(现在带着天气资料)又回到 ChatModel,它这次不调工具了,直接给出人话「北京今天晴,25℃」——最后一个事件。
迭代器 ok=false,我的旅程结束。你看到的就是我一路攒下来的最后那条完整消息。
L06

react 循环:Agent 的心跳(直觉)

你上面那个 Agent,收到"北京天气"后内部转的圈就是 ReAct 循环(Day 12/15 精读真实代码,今天先建立直觉):

你的问题 ChatModel模型思考 要调工具?查天气 执行工具拿到天气 结果喂回模型 ↑ 模型还要调工具 → 回到"ChatModel"再来一轮;不调了 → 输出最终答案,结束
ReAct = Reason + Act(推理 + 行动):模型先"想"(要不要用工具、用哪个),然后"做"(调工具),看到结果再想下一步,循环往复直到能给最终答案。Eino 的 ChatModelAgent 内部就是用一张 compose 图搭出这个循环的(Day 12 会看到真实代码:模型节点 + 工具节点 + 一条"回到模型"的边)。MaxIterations(默认 20)是循环上限,防止无限转圈。
💡 生活类比:ReAct 循环 = 实习生查资料完成任务 实习生接到「查北京天气写一句话」:先「我不知道,得查」(Reason)→ 动手打电话问天气台(Act)→ 看结果「哦,晴 25℃」→ 再「够了,可以答了」→ 交活。要是资料不够,他会再打一个电话(再转一圈)。MaxIterations=20 就像规定「最多打 20 个电话,别没完没了」。

用一张走查表跟踪「北京天气」这个输入在循环里每一圈发生了什么、消息列表长成什么样:

此刻发生什么消息列表末尾新增
入口用户提问进来User:"北京天气?"
第 1 圈模型思考 → 决定调工具Assistant{ToolCalls:[get_weather("北京")]}
(工具)执行 get_weather,拿到结果Tool:"晴 25℃"
第 2 圈模型看到工具结果 → 这次不调了Assistant:"北京今天晴,25℃"
结束无 ToolCalls,循环退出(输出最终答案)
⚠️ 小白常误以为:Agent 调一次工具就结束了。其实:只要模型回复里还带 ToolCalls,就会再转一圈——它可能连着查天气、查航班、查酒店好几轮,直到某一圈模型不再要求调工具,才输出最终答案。上表里"第 2 圈"模型没再要工具,循环才停。
🧠 一句话记住 「想 → 调 → 看 → 再想,不调了就收工」——这就是 ReAct,也是后面所有 Agent 的心跳。
L07

定义一个工具:一个 Go 函数就够

那个"查天气"的工具怎么来?Eino 提供了从普通 Go 函数自动生成工具的能力(components/tool/utilsInferTool):

type WeatherReq struct {
    City string `json:"city" jsonschema:"description=城市名"`   // struct tag 描述参数
}
// 一个普通函数 → 自动变成一个 Tool
weatherTool, _ := utils.InferTool(
    "get_weather",              // 工具名
    "查询某城市的天气",           // 描述(给模型看,决定何时调用)
    func(ctx context.Context, req WeatherReq) (string, error) {
        return "北京今天晴,25℃", nil   // 你的真实逻辑
    },
)
InferTool 帮你干了什么? 它读你函数的参数类型(WeatherReq)和 struct tag,自动生成给模型看的参数 JSON Schema(Day 03 讲的 ToolInfo),还自动处理"模型传来的 JSON 字符串 → 你的 struct"和"你的返回值 → 回给模型的字符串"的编解码。你只写业务逻辑,工具的 schema 和编解码全自动。这跟 claude-code 教程 Day 07 讲的"zod schema 一处声明两处受益"异曲同工——都是"从类型自动生成工具描述"。

把这个 weatherTool 放进 L04 第 2 步的 ToolsConfig.Tools 里,Agent 就会在需要时自己调它。

💡 生活类比:给工具 = 给实习生配一部"专线电话" 延续实习生世界观:你的实习生(模型)本身不知道实时天气,但你在他桌上放了一部标着「查天气热线」的电话(工具)。InferToolname + description 就是电话上的标签——实习生正是靠这个标签判断「这个任务该拿起哪部电话」。标签写得越清楚(description 越准),他越不会打错电话。一句话记住:工具好不好用,一半看描述写得清不清楚。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么要拉 eino + eino-ext 两个仓?
  • 造一个 Agent 的五步(模型→Agent→Runner→Query→消费事件)?
  • Agent 的 Run 为什么返回迭代器而非返回值?(流式多步,边跑边吐事件)
  • ReAct 循环是什么?MaxIterations 防什么?
  • 怎么用一个 Go 函数定义工具?(InferTool 自动生成 schema + 编解码)

✋ 动手

# 1. 看 Agent 接口定义(L05)
sed -n '453,467p' adk/interface.go

# 2. 看 ChatModelAgent 构造与 Runner
grep -n "func NewChatModelAgent\|func NewRunner\|func.*Query" adk/chatmodel.go adk/runner.go | head

# 3. 看事件结构
sed -n '419,438p' adk/interface.go

# 4. 看工具接口(明天/Day04 细读)
sed -n '1,60p' components/tool/interface.go | head -40
明天预告 · Day 03:Agent 跑起来了,我们往下钻——最底层的数据类型 schema。重点是 Message(一个结构体怎么同时当输入/输出/模板)和 StreamReader(Eino 最精妙的流式抽象)。
← Day 01 项目全景 Day 03 · schema 核心数据类型 →