Eino 的 ReAct 循环是怎么跑起来的:图、节点、分支

系列「企业级 AI Agent 实现拆解」补充篇。上一篇是系列收尾复盘,这篇单独拆出来,专门看 Eino 框架内部是怎么实现 ReAct 循环的------也就是我们在ReAct 循环的 50 行 Go 实现,逐行拆解里那句"交给 Eino 的 react.NewAgent() 跑"背后到底发生了什么。


先搞懂 ReAct 是什么

ReAct 是 Reason(推理)+ Act(行动)的缩写。核心思想很简单:

  1. LLM 拿到用户问题和可用工具列表
  2. LLM 决定是直接回答 还是调一个工具
  3. 如果调工具,拿到结果后回到第 2 步重新判断
  4. 如果直接回答,循环结束

用伪代码写就是:

复制代码
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)。编译做了几件事:

  1. 检查图有没有环(ReAct 图有环:tools → chat → tools → ...,所以需要用 Pregel 模式支持有环图)
  2. 给每个节点分配 channel(节点之间传数据的管道)
  3. 注册 MaxStep 限制(防止无限循环,见 WithMaxRunSteps
  4. 绑定 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 的输出后,要做的事很简单:

  1. 从 LLM 输出中提取所有 ToolCall(工具名 + 参数 JSON)
  2. 对每个 ToolCall,找到对应的工具实现
  3. 并行执行所有工具调用(默认行为)
  4. 把每个工具的结果包装成 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 篇。

相关推荐
熊猫钓鱼>_>4 小时前
腾讯云 COS × WorkBuddy X skill:实现我的游戏项目资源管理自动化“龙虾”
游戏·自动化·腾讯云·agent·cos·skill·workbuddy
摸鱼同学5 小时前
04-Embedding 和向量数据库:让机器真正理解语义
ai·chatgpt·embedding·agent·向量数据库
Hiter_John5 小时前
Golang的运算符
开发语言·后端·golang
人工智能培训6 小时前
打造行业知识图谱三步走
大数据·人工智能·机器学习·3d·知识图谱·agent
Hiter_John6 小时前
Golang的变量常量初始化
开发语言·后端·golang
十正7 小时前
Claude code源码精读之蜂群模式
javascript·人工智能·agent·claude code
陈如水8 小时前
Agent Skill
agent
谢白羽9 小时前
SimpleMem:长期记忆不是存得更多,而是让每个 token 更有信息密度
大模型·llm·agent·agent memory
ckjoker9 小时前
四大AI Agent架构拆解:我手敲了一个迷你版,发现了7条可迁移的设计原则
python·agent