系列「企业级 AI Agent 实现拆解」补充篇。上一篇是系列收尾复盘,这篇单独拆出来,专门看 Eino 框架内部是怎么实现 ReAct 循环的------也就是我们在ReAct 循环的 50 行 Go 实现,逐行拆解里那句"交给 Eino 的
react.NewAgent()跑"背后到底发生了什么。
先搞懂 ReAct 是什么
ReAct 是 Reason(推理)+ Act(行动)的缩写。核心思想很简单:
- LLM 拿到用户问题和可用工具列表
- LLM 决定是直接回答 还是调一个工具
- 如果调工具,拿到结果后回到第 2 步重新判断
- 如果直接回答,循环结束
用伪代码写就是:
while True:
response = LLM(问题 + 历史消息 + 工具列表)
if response 没有 tool_calls:
return response.content # 直接回答,结束
for tool_call in response.tool_calls:
result = 执行工具(tool_call)
历史 = 历史 + [response, result]
就这么简单。难点不在循环本身,而在怎么把循环和状态管理、流式输出、中断恢复这些工程需求组合在一起。Eino 的做法是把循环建模成一张有向图。
Eino 的 ReAct 图长什么样
Eino 内部不手写 for 循环,而是把 ReAct 的每一步画成图里的节点,数据在节点之间流动:
START
│
▼
┌──────────┐
│ chat │ ← LLM 推理节点
│ (ChatModel)│
└────┬─────┘
│
▼
有 tool_calls 吗? ← 分支路由
│
┌──┴──┐
│ │
是 否
│ │
▼ ▼
┌──────┐ END(返回最终回答)
│tools │
│(工具 │ ← 并行执行所有 tool_calls
│ 执行) │
└──┬───┘
│
▼
back to chat ← 回到 LLM 节点,继续推理
三个关键概念:节点 (Node)、边 (Edge)、分支 (Branch)。Eino 源码里定义了两个核心节点 key:nodeKeyModel="chat" 和 nodeKeyTools="tools",配置结构体是 AgentConfig。
- 节点 :一个执行步骤。
chat节点调 LLM,tools节点执行工具 - 边 :节点之间的连线,数据沿着边流动。
START → chat是一条边,tools → chat是另一条边 - 分支 :不是固定连线,而是运行时动态判断 走哪条路。
chat节点后面有一个分支:检查 LLM 的输出,有 tool_calls 就走tools,没有就走END
代码:怎么把这张图画出来
图的构建
go
// Eino 内部 [react.go --- NewAgent 函数(简化)](https://github.com/cloudwego/eino/blob/main/flow/agent/react/react.go#L284)
func NewAgent(ctx context.Context, config *AgentConfig) (*Agent, error) {
g := compose.NewGraph(...)
// 1. 添加 LLM 节点 [AddChatModelNode](https://github.com/cloudwego/eino/blob/main/flow/agent/react/react.go#L349)
g.AddChatModelNode(nodeKeyModel, config.Model, ...)
// 2. 添加工具节点 [AddToolsNode](https://github.com/cloudwego/eino/blob/main/flow/agent/react/react.go#L365)
g.AddToolsNode(nodeKeyTools, config.ToolsConfig, ...)
// 3. START → chat
g.AddEdge(compose.START, nodeKeyModel)
// 4. chat 后面的分支路由 [AddBranch](https://github.com/cloudwego/eino/blob/main/flow/agent/react/react.go#L369)
g.AddBranch(nodeKeyModel, compose.NewStreamGraphBranch(
func(ctx context.Context, sr *schema.StreamReader[*schema.Message]) (string, error) {
// 读 LLM 流式输出的第一个 chunk
if hasToolCalls(sr) {
return nodeKeyTools, nil // 有工具调用 → 走 tools 节点
}
return compose.END, nil // 没有工具调用 → 结束
},
))
// 5. tools → chat(工具执行完,回到 LLM 继续推理)
g.AddEdge(nodeKeyTools, nodeKeyModel)
// 6. 编译图 [Compile](https://github.com/cloudwego/eino/blob/main/flow/agent/react/react.go#L386)
runnable, err := g.Compile(ctx, ...)
return &Agent{runnable: runnable}, nil
}
第 4 步的分支路由是 ReAct 循环的核心判断 。它消费 LLM 的流式输出,看第一个 chunk 里有没有 ToolCalls。有就走 tools 节点,没有就结束。(判断逻辑实现在 firstChunkStreamToolCallChecker)
这个判断在流式输出的最开始 就能做出------因为 LLM 在生成第一个 chunk 时,tool_calls 字段就已经决定了(要么有要么没有,不会中途冒出来)。所以 Eino 不需要等整个回答生成完,就能判断要不要执行工具。
图的编译
g.Compile() 把这张图编译成一个 Runnable(源码见 react.go:386)。编译做了几件事:
- 检查图有没有环(ReAct 图有环:
tools → chat → tools → ...,所以需要用 Pregel 模式支持有环图) - 给每个节点分配 channel(节点之间传数据的管道)
- 注册
MaxStep限制(防止无限循环,见 WithMaxRunSteps) - 绑定
CheckPointStore(如果传了的话,用于 HITL 中断恢复,见 WithCheckPointID)
编译产物是一个 Runnable------一个可以反复调用的执行单元,输入消息列表,输出 LLM 的最终回答。
图的运行:Eino 主循环怎么跑
编译好之后,调 runnable.Stream(ctx, messages) 就开始执行。Eino 的主循环是这样的:
1. 初始化:把输入消息喂进 START → chat 的边
2. 取第一个待执行节点:chat
3. 执行 chat 节点:调 LLM,拿到流式输出
4. 执行 chat 后面的分支:检查有没有 tool_calls
- 有 → 创建 tools 节点的 task
- 没有 → 创建 END 节点的 task
5. 取下一个待执行节点
6. 如果是 tools:
a. 执行 tools 节点:并行调所有工具
b. 创建 chat 节点的 task(回到第 3 步)
7. 如果是 END:返回结果
8. 每一步检查 MaxStep,超限则报错退出
用代码看更清楚(graph_run.go 简化版):
go
for step := 0; ; step++ {
if step >= maxSteps {
return ErrExceedMaxSteps // 防死循环
}
// 提交当前批次的 task
tm.submit(ctx, tasks)
tm.wait() // 等所有 task 执行完
// 解析结果,计算下一步
completedTasks := tm.resolveCompletedTasks()
// 计算下一批 task(通过分支路由)
nextTasks := calculateNextTasks(completedTasks)
if nextTasks 包含 END {
return 结果 // 循环结束
}
tasks = nextTasks // 继续下一轮
}
每轮执行一批节点,等它们全完成,再根据分支路由计算下一批要执行的节点。这个"等一批全完成再继续"的模式叫 Pregel 模型------Google 的图计算框架 Pregel 就是这么干的,Eino 借鉴了这个思路。
工具节点怎么执行
tools 节点收到 LLM 的输出后,要做的事很简单:
- 从 LLM 输出中提取所有
ToolCall(工具名 + 参数 JSON) - 对每个
ToolCall,找到对应的工具实现 - 并行执行所有工具调用(默认行为)
- 把每个工具的结果包装成
ToolMessage
go
// [tool_node.go](https://github.com/cloudwego/eino/blob/main/compose/tool_node.go#L1046) 简化
func (tn *ToolsNode) Invoke(ctx context.Context, msg *schema.Message, ...) ([]*schema.Message, error) {
var results []*schema.Message
for _, tc := range msg.ToolCalls {
tool := tn.tools[tc.Function.Name] // 按名找工具
result, err := tool.InvokableRun(ctx, tc.Function.Arguments)
results = append(results, &schema.Message{
Role: "tool",
Content: result,
ToolCallID: tc.ID, // 关联到 LLM 发出的那个 call
})
}
return results, nil
}
ToolCallID 很重要------LLM 可能一次发多个工具调用,每个结果都要用 ID 对回去,LLM 才知道哪个结果是哪个调用的。
我们的适配层:怎么把 Eino 接到自己的系统
前面全是 Eino 框架内部的事。但 Eino 不知道什么是 Session、什么是 Hook、什么是多租户------这些是我们的领域逻辑。连接 Eino 和我们的系统,靠的是一层适配器,在 infrastructure/einoadapter/ 包里。
适配器的角色
打个比方:Eino 是一个通用电机,我们的系统是一台定制的机器。适配器就是连接电机和机器的传动轴------电机不关心你用它驱动什么,机器也不需要知道电机的内部构造。
一共有 5 个适配器:
| 适配器 | 文件 | 把什么转成什么 |
|---|---|---|
chatModelAdapter |
chatmodel.go | 我们的 port.LLMClient → Eino 的 ChatModel |
toolBrokerAdapter |
tool.go | 我们的 port.ToolBroker → Eino 的 InvokableTool |
SessionCheckpointStore |
checkpoint.go | 我们的 SessionRepo → Eino 的 CheckPointStore |
AgentCallback |
callback.go | 我们的 HookRunner + Streamer → Eino 的 callbacks.Handler |
tokenStreamReader |
factory.go | Eino 的 StreamReader[Message] → 我们的 port.TokenReader |
每个适配器做的事情都一样:一边是 Eino 的接口,一边是我们的 port 接口,中间做格式转换。举两个例子。
Eino 的核心接口源码:
ToolCallingChatModel(LLM 接口)、InvokableTool(工具接口)、CheckPointStore(检查点存储接口)。
例子一:LLM 适配器
Eino 要求 LLM 实现 ToolCallingChatModel 接口(有 Stream 方法,输入 []*schema.Message,输出 StreamReader[*schema.Message])。我们的 port.LLMClient 的接口不一样(输入 ChatRequest,输出 <-chan StreamChunk)。
适配器在中间做桥接:
go
// chatmodel.go --- Stream 方法(简化)
func (a *chatModelAdapter) Stream(ctx context.Context, input []*schema.Message, ...) (StreamReader[*schema.Message], error) {
// 1. 转换消息格式:Eino Message → 我们的 ChatRequest
req := buildRequest(a, input)
// 2. 调我们的 LLMClient
ch, err := a.client.Stream(ctx, a.profile, req)
// 3. 把 channel 包装成 Eino 的 StreamReader
pipe := schema.Pipe[*schema.Message](16)
go func() {
for chunk := range ch {
einoMsg := llmChunkToEinoMessage(chunk) // 格式转换
pipe.Send(einoMsg)
}
pipe.Close()
}()
return pipe.Reader(), nil
}
启动一个 goroutine,把我们的 channel 逐个读出来,转成 Eino 的消息格式,写进 Eino 的流式管道。两边的数据格式不一样,但流动方式是一样的------都是"生产者写,消费者读"。
例子二:工具适配器
Eino 要求工具实现 InvokableTool 接口(有 Info() 返回元数据,InvokableRun() 执行调用)。我们的工具在 ToolBroker 后面(可能走 gRPC 调另一个服务)。
go
// tool.go --- InvokableRun 方法(简化)
func (t *toolBrokerAdapter) InvokableRun(ctx context.Context, argsJSON string, ...) (string, error) {
sessionID := ctx.Value(port.ContextKeyAgentSessionID{}).(string)
return t.broker.Invoke(ctx, t.tenantID, sessionID, model.ToolCall{
Name: t.name,
Arguments: argsJSON,
})
}
Eino 传过来的是 JSON 字符串格式的参数,我们直接透传给 ToolBroker.Invoke,返回值也是字符串。这个适配器几乎不需要做格式转换。
一张完整的调用链路图
把上面所有内容串起来,从用户发消息到收到回答的完整链路:
用户发消息
│
▼
RunTurnHandler.Handle() ← 我们的 application 层
│ 加载 session、组装 history、context 注入
│
▼
ReActFactory.StreamTurn() ← 我们的 infrastructure 层
│ 查缓存 / 编译新图
│ 组装 compose 选项(checkpoint、callback、ForceNewRun)
│
▼
runnable.Stream(ctx, inputMsgs) ← Eino 框架入口
│
├── Eino 主循环 step 1:
│ chat 节点执行
│ ├── chatModelAdapter.Stream() ← 我们的 LLMClient
│ │ └── client.Stream() → goroutine 桥接 channel → StreamReader
│ │
│ └── callback.OnStart → BeforeModelCall hook ← 我们的 HookRunner
│ └── callback.OnEnd → AfterModelCall hook
│
├── 分支路由:检查 tool_calls
│ ├── 有 → 继续
│ └── 没有 → END → 返回流
│
├── Eino 主循环 step 2:
│ tools 节点执行
│ ├── toolBrokerAdapter.InvokableRun() ← 我们的 ToolBroker
│ │ └── broker.Invoke() → gRPC → tool-broker 服务
│ │
│ └── callback.OnStart → BeforeToolUse hook
│ └── callback.OnEnd → AfterToolUse hook
│
├── tools → chat(回到 step 1,继续循环)
│
└── ... 直到 LLM 不再产生 tool_calls
│
▼
tokenStreamReader.Recv() ← 我们的 port.TokenReader
│ 从 Eino 流里逐个取 content
│ 每取一个 token,同时 stream.Emit 推 SSE
│
▼
consumeEinoStream() 收集最终内容 ← 我们的 application 层
│
▼
sess.Complete() + commitFinalAnswer() ← 落库、发事件、推 done
小结
Eino 的 ReAct 实现可以概括为一句话:把循环画成图,让数据在节点间流动,用分支路由控制方向。
对我们来说,Eino 解决的是"ReAct 循环怎么跑"的问题。我们不用自己写 for 循环、不用自己管理工具调用的并行、不用自己实现 MaxStep 限制。但 Eino 不管的事------Session 状态、多租户隔离、Hook 钩子、事务落库、记忆召回------这些还是得在我们的 application/domain 层自己实现。两者通过 5 个薄适配器桥接,各管各的。
本文是「企业级 AI Agent 实现拆解」补充篇。整个系列从 DDD 架构选型到 ReAct 循环、HITL 中断、SSE 推流、Hook 系统、工具调用、多 Provider、知识库、长期记忆、多租户、审计日志、可观测性,一路拆到复盘,共 15 篇。