从零推导一个现代 ReAct Agent框架

拨开迷雾看本质:从零推导一个现代 ReAct 框架

1. 为什么我们需要一个 Agent 框架?

ReAct(Reasoning + Acting)是 Agent 最基础的循环模式,它的核心思想非常简单:观察 -> 思考 -> 行动 -> 观察...,直到完成任务。

如果仅仅是实现这个概念,我们真的需要引入复杂的框架吗?如果自己手写一个极简的 ReAct(比如用原生 Go),代码大概长这样:

go 复制代码
// 手写版 ReAct (Pseudo-code)
func RunReAct(history []Message) Message {
    for i := 0; i < MaxSteps; i++ {
        // 1. Model 思考
        resp := llm.Generate(history)
        history = append(history, resp)
        
        // 2. 判断是否要调工具
        if !resp.HasToolCalls() {
            return resp // 结束
        }
        
        // 3. 执行工具
        toolResult := tools.Run(resp.ToolCalls)
        history = append(history, toolResult)
        
        // 4. 循环回去继续思考...
    }
}

2. 第一步演进:解决流式返回(Streaming)痛点

问题场景

上面的代码是同步阻塞的。如果这是一个面向用户的 Chatbot,用户希望看到"打字机效果"(一边思考一边出字)。但上面的 llm.Generate 必须等模型完全生成完才能返回。

如果你把 llm.Generate 改成返回 chan MessageChunk,你的代码会变成这样:

go 复制代码
func RunReActStream(history []Message, out chan<- string) {
    for i := 0; i < MaxSteps; i++ {
        // 必须监听 chunk 并发往前端
        chunkCh := llm.GenerateStream(history)
        var fullResp Message
        for chunk := range chunkCh {
            out <- chunk.Content
            fullResp = merge(fullResp, chunk)
        }
        history = append(history, fullResp)
        
        // 如果是 ToolCall 怎么办?模型吐出的 chunk 可能是工具参数的 JSON 片段!
        if fullResp.HasToolCalls() {
            // 你还得处理工具的流式输出
            toolCh := tools.RunStream(fullResp.ToolCalls)
            for tChunk := range toolCh {
                out <- tChunk
            }
        } else {
            return
        }
    }
}

批判性思考

你会发现,为了支持流式,整个业务逻辑被 for chunk := range 淹没了。如果你的 Tool 也是流式的(比如执行一个跑了 5 分钟的 Python 脚本,不断打印 stdout),你还得小心处理各种 channel 的关闭和 error。

如果自己做框架,怎么解?

我们需要一个统一的 Streamable 接口,把所有的执行单元(Model, Tool)都包起来,然后提供一个引擎自动处理 Channel 的对接(Pipe)。这样业务编排代码 就不用写 for ... range 了,而组件实现代码也只需要专注处理自己的那一段流。

引入 Streamable 抽象后的本质代码

框架层定义一套流式节点和连接器(Pipe),业务代码只需要把节点"连"起来。

你可能会问:"那内部处理流不也是很复杂吗?"
答案是:确实复杂,但复杂度被隔离了。 在没有框架时,业务的 for 循环既要处理"模型流"又要处理"工具流",还要做合并。有了框架后,处理流的逻辑被下沉到了具体的 Node 内部,且可以被高度复用

来看下 Node 内部是怎么处理流的(以 ModelNode 为例):

go 复制代码
// 框架层:定义流式节点接口
type StreamNode interface {
    // 接收上游的流,返回自己的流
    Process(in <-chan Message) <-chan Message 
}

// 框架层实现:模型节点 (内部封装了流的消费与生产)
// 这里的代码虽然要写 for range,但它是一劳永逸的,写完后所有业务都能直接用
type ModelNode struct { llm LLM }
func (n *ModelNode) Process(in <-chan Message) <-chan Message {
    out := make(chan Message)
    go func() {
        defer close(out)
        var history []Message
        
        // 1. 消费上游流,聚合成完整输入
        for msg := range in {
            history = append(history, msg)
        }
        
        // 2. 调用底层大模型流式接口
        llmStream := n.llm.GenerateStream(history)
        
        // 3. 把大模型的流转发给下游
        for chunk := range llmStream {
            out <- chunk
        }
    }()
    return out
}

// 框架层:实现一个管道器,把上游的输出对接到下游的输入
func Pipe(node1, node2 StreamNode, in <-chan Message) <-chan Message {
    out1 := node1.Process(in)
    return node2.Process(out1)
}

// =========================================================
// 业务代码:完全没有 channel 和 goroutine!
// =========================================================
func RunReActWithPipe(history []Message) {
    // 将历史消息转化为一个初始流
    initStream := make(chan Message, len(history))
    for _, m := range history { initStream <- m }
    close(initStream)
    
    // 声明节点(直接复用框架写好的流式节点)
    modelNode := &ModelNode{llm: myLLM}
    toolNode := &ToolNode{tools: myTools} // ToolNode 内部同理
    
    // 把节点连起来:init -> model -> tool -> model -> ...
    // 此时业务代码完全看不到 channel 里的具体 chunk
    currentStream := initStream
    for i := 0; i < MaxSteps; i++ {
        currentStream = Pipe(modelNode, toolNode, currentStream)
    }
}

总结: 内部处理流确实复杂,但这种复杂性从**"业务逻辑中"被抽离到了 "可复用的组件库中"**。在 Eino 中,你调用的 g.AddChatModelNode 底层就是框架为你写好的类似 ModelNode.Process 的逻辑,它替你处理了所有的并发和阻塞。

3. 第二步演进:解决状态丢失与人工介入(Human-in-the-loop)痛点

问题场景

假设你的循环跑到了第 19 次,调用了一个 DeleteDatabase 的工具。这个工具要求用户输入验证码才能继续。

在上面的代码中,你怎么暂停?

如果你 return 退出函数,那么当前的 historyi(循环次数)就全部丢失了。等用户 5 分钟后输入了验证码,你怎么回到第 19 次循环的那行代码继续往下跑?

批判性思考

普通的函数调用栈是不可被序列化和持久化 的。

为了实现"挂起-恢复",你必须把循环改成状态机(State Machine) 。每次循环不仅要更新 history,还要把当前走到了哪一步(是准备调模型,还是准备调工具)存到一个可以序列化的结构体里。

go 复制代码
// 必须把局部变量变成可序列化的状态
type ReActState struct {
    History []Message
    Step    int
    Next    string // "Model" or "Tool"
}

func RunStateMachine(state *ReActState) {
    for {
        switch state.Next {
        case "Model":
            resp := llm.Generate(state.History)
            state.History = append(state.History, resp)
            if resp.HasToolCalls() {
                state.Next = "Tool"
            } else {
                return // 结束
            }
        case "Tool":
            // 如果需要人介入,这里直接 return,把 state 存入数据库
            if needHuman(state) {
                SaveToDB(state)
                return 
            }
            res := tools.Run(state.History.Last())
            state.History = append(state.History, res)
            state.Next = "Model"
        }
    }
}

4. 融合第一步与第二步:Stream + State

你可能会问:第一步我们搞出了 StreamNodePipe,第二步搞出了 ReActState 状态机,这两者怎么结合?流式处理的通道(Channel)是没法序列化存进数据库的啊!

批判性思考

这也是所有大模型框架最头疼的地方:流式执行(动态过程)和状态持久化(静态快照)天生冲突。

如果节点正在不断吐 Stream,这时候系统断电了,怎么恢复?

结合后的本质解法:

我们不能持久化 Channel,我们只能在节点执行完毕,流关闭的那一瞬间 去持久化状态(Checkpoint)。

因此,StreamNode 的接口必须升级:它不仅要处理流,还要在处理完流后,把最新的内容写回全局 State

go 复制代码
// 框架层:既支持流,又支持状态管理的 Node
type Node interface {
    // 接收上游流,返回自己的流,并在内部流处理结束后,更新全局 state
    Process(state *ReActState, in <-chan Message) <-chan Message 
}

// 框架层实现:结合了 State 的模型节点
type ModelNode struct { llm LLM }
func (n *ModelNode) Process(state *ReActState, in <-chan Message) <-chan Message {
    out := make(chan Message)
    go func() {
        defer close(out)
        
        // 1. 调用底层大模型流式接口(基于 state 里的历史)
        llmStream := n.llm.GenerateStream(state.History)
        
        var fullResp Message
        // 2. 一边把流往外发,一边把结果聚合并拼起来
        for chunk := range llmStream {
            out <- chunk
            fullResp = merge(fullResp, chunk)
        }
        
        // 3. 关键!流彻底结束时,更新全局 State 状态
        state.History = append(state.History, fullResp)
        if fullResp.HasToolCalls() {
            state.Next = "Tool"
        } else {
            state.Next = "END"
        }
        
        // 4. 触发框架层的自动保存(Checkpoint)
        SaveToDB(state)
    }()
    return out
}

这样,我们就把 "流式展示""离线状态" 完美结合了:

  • 前端通过监听 out channel 实现了打字机效果。
  • 后端通过 SaveToDB(state) 实现了节点级别的持久化。如果下一个节点(Tool)被挂起,我们随时可以从数据库捞出 ReActState,并根据 state.Next == "Tool" 继续往下跑。

5. 第三步演进:应对复杂的控制流与切面(Branch & Middleware)

问题场景

随着业务变复杂,你的需求增加了:

  1. 有些工具(比如 TransferToAgentExit)执行完后,不需要把结果喂回给模型,而是直接退出整个 ReAct 循环。
  2. 每次调模型前,需要把 history 里过长的消息做一下摘要(Reduction)。

在状态机代码里,你会怎么加?

你会发现 switch case 变得极其臃肿,充满了 if tool == "Exit" { return } 这样的硬编码特判。代码变得难以测试和复用。

批判性思考

如果状态机的流转规则(Next 去哪)和节点本身的逻辑(怎么执行)耦合在一起,系统就无法扩展。

我们需要把**"图的拓扑结构"(谁连着谁,怎么分支)和"节点的具体实现"**分离开。

引入 StreamGraph (流式图引擎) 后的本质代码

既然我们已经有了"流式+状态"的 Node 接口,我们需要把图引擎也升级为能处理流(Pipe)的执行器 。一旦我们将控制流抽离出来,代码就不再是 switch/case,而是声明一张"有向图",让一个引擎去负责自动 Pipe 和流转。

go 复制代码
// 框架层:定义分支函数(注意:分支决断是在流结束,状态更新后发生的)
type BranchFunc func(state *ReActState) string

// 框架层:流式图引擎
type StreamGraph struct {
    nodes    map[string]Node
    edges    map[string]string
    branches map[string]BranchFunc
}

// 框架层:流式图执行引擎 (核心!)
func (g *StreamGraph) RunStream(startNode string, state *ReActState, in <-chan Message) <-chan Message {
    // 创建一个最终暴露给用户的输出流
    finalOut := make(chan Message)
    
    go func() {
        defer close(finalOut)
        currNode := startNode
        currentStream := in
        
        for currNode != "END" {
            // 1. 获取当前节点
            node := g.nodes[currNode]
            
            // 2. 将上游流 pipe 给当前节点,得到当前节点的输出流
            outStream := node.Process(state, currentStream)
            
            // 3. 消费当前节点的流(一边吐给最终用户,一边等它结束)
            // 注意:这里阻塞等待流结束,流结束意味着节点跑完了,state 也被更新了!
            for chunk := range outStream {
                finalOut <- chunk
            }
            
            // 4. 流结束了,开始进行拓扑寻址,决定下一个节点
            if branch, ok := g.branches[currNode]; ok {
                // 基于最新更新的 state 做决断
                currNode = branch(state) 
            } else {
                currNode = g.edges[currNode]
            }
            
            // 5. 下一个节点没有输入流(因为上一个流已经耗尽),传一个空的闭合流启动它
            emptyStream := make(chan Message)
            close(emptyStream)
            currentStream = emptyStream
        }
    }()
    
    return finalOut
}

// 业务代码:此时你只需要"连线",不需要写循环,也不需要处理流
func BuildReActGraph() *StreamGraph {
    g := NewStreamGraph()
    g.AddNode("Model", &ModelNode{llm: myLLM})
    g.AddNode("Tool", &ToolNode{tools: myTools})
    
    // 静态连线:Tool 跑完永远回到 Model
    g.AddEdge("Tool", "Model") 
    
    // 动态分支:Model 跑完,根据 state 里记录的模型输出决定去向
    g.AddBranch("Model", func(state *ReActState) string {
        lastMsg := state.History[len(state.History)-1]
        if lastMsg.HasToolCalls() {
            // 如果遇到特殊的直接退出工具
            if lastMsg.ToolCalls[0].Name == "Exit" {
                return "END"
            }
            return "Tool"
        }
        return "END"
    })
    
    return g
}

通过这种改造:

  1. 流被完美对接了 :图引擎的 RunStream 负责把节点的流输出到前端。
  2. 状态被隔离了 :分支决断(BranchFunc)不再依赖偷窥流里的数据,而是等待流结束、节点把数据写回 State 后,再基于静态的 State 做出优雅的判断。
  3. 扩展性极强 :当你需要新增一个"直接退出的工具"(比如 Exit)时,你只需要在图上改一下 Branch 函数,而完全不需要去碰核心的执行引擎和流处理逻辑。这就是 Eino 走向 Graph 编排的终极原因。

6. 映射到现实:Eino 的 adk/react.go 到底做了什么?

当我们带着这套"本质推导"去审视真实的 adk/react.go 时,你会发现它比我们的推导又多了一层抽象

我们的推导是:手写循环 -> 抽离流(Pipe) -> 抽离状态机(State) -> 抽离图引擎(Graph)。

在我们的推导里,业务开发者依然需要自己去 BuildReActGraph(),自己去画这三四个节点的图。

而 Eino adk/react.go 的本质是:一个高度封装的图生成器(Graph Builder)

  1. 它固化了拓扑结构newReact 函数(L340)内部写死了 Init -> ChatModel <-> ToolNode 的图结构。这意味着,使用 ADK 的开发者连图都不需要画了。ADK 认为:"所有的 ReAct 逻辑,本质拓扑都是这张图"。
  2. 它把图的灵活性转化为了配置项 :既然图结构写死了,业务怎么扩展?ADK 的解法是提供配置(如 config.toolsReturnDirectly)。如果配置了这个项,newReact 就会在构建图时,自动在内部多插入一个 toolNodeToEndConverter 节点和一段 BranchL430-L469)。
  3. 它隐藏了 State 的脏活 :在我们的推导里,Model 节点需要自己把结果 append 到 State 里。但在 Eino 中,这些动作被拆分成了钩子函数。比如 modelPreHandleL364)用来扣减迭代次数,toolPreHandleL374)用来在调用工具前从 State 提取当前工具并判断是否要直接返回。

批判性总结:

从"自己手写"到"Eino compose",是从过程式走向声明式(控制流解耦)

从"Eino compose"到"Eino ADK (react.go)",是从通用编排走向领域特化(模式固化)

  • 优势:极度降低了上手门槛。开发者只需要提供一个 Model 和几个 Tools,框架就自动编译出一张具备流式、持久化、重试、直接返回等所有高级特性的工业级 ReAct 图。这就是为什么 ADK 被称为"Agent 运行时",而不是"图引擎"。
  • 代价(批判性看) :你彻底失去了对底层拓扑的控制权。如果你想在 Model 和 Tool 之间插一个全新的"外部系统校验节点",你做不到。你只能被逼着用 Middleware 的 WrapModel 或者 StatePreHandler 去"Hack"这个固化的生命周期。框架越贴心(ADK),它的侵入性就越强,越难以应对超出"ReAct"或"Plan-Execute"经典范式之外的创新架构。

7. 终极拷问:什么样的 ReAct 框架才是最优秀的?Eino 完美了吗?

如果从"本质"出发,去衡量一个 ReAct 框架(或者更广泛的 Agent 框架)是否优秀,核心指标只有两个字:"伸缩性(Scalability of Abstraction)"

这里的伸缩性不是指 QPS 并发,而是指抽象层级的伸缩性

  • 当用户只想写一个简单的 Demo 时,框架能不能让他像写 for 循环一样直觉、简单?
  • 当用户要写一个企业级重型系统时,框架能不能接得住流式、容错、分布式挂起和复杂编排?

基于这个标准,我们来批判性地审视 Eino 的设计。

Eino 做的极好的地方(已经触及本质):
  1. 彻底解决了"流与状态"的冲突 :通过 compose 层的 StreamReaderProcessState,Eino 几乎是目前开源界把 Streaming 和 Checkpoint 结合得最深、最优雅的框架。它没有像 LangChain 那样在 Python 的异步地狱里挣扎,而是用强类型的 Graph 状态机把这俩"水火不容"的东西缝合了。
  2. 多层级的降级逃生舱 :Eino 并没有只提供 ADK。如果你觉得 adk/react.go 太死板,你可以降级去用 compose 手画图;如果觉得画图太重,你可以直接调 components/model。这种分层设计(Component -> Compose -> ADK)是优秀的。
Eino 缺乏什么?(距离"最优秀"还有多远):

尽管底层引擎很强,但从开发者体验(DX)的本质来看,Eino 依然有明显的痛点:

  1. 类型体操的过度负担(Type Gymnastics Overhead)

    • 痛点 :为了在强类型的 Go 语言中实现通用的 Graph,Eino 引入了大量的泛型、any 转换和 ProcessState 闭包。在 adk/react.go 中,你会看到 st.internals 这种用 map[string]any 存内部状态的设计。
    • 本质缺陷:这违背了 Go 语言"简单直白"的哲学。当业务开发者需要去 Debug 一个 Graph 报错时,他面对的是深不见底的泛型闭包调用栈,心智负担极高。
    • 最优秀的做法 :理想的框架应该能通过**代码生成(Code Generation)**或者更轻量的接口,让开发者写强类型代码,而框架在编译期自动生成 Graph 编排代码(类似 Google 的 Wire 或者 Go 自身的 go generate)。
  2. 控制流的过度"图化"(Over-Graphification)

    • 痛点 :Eino 强制要求所有的控制流(比如 ReturnDirectly 直接返回)都必须翻译成图的分支(Branch)节点(Node)
    • 本质缺陷 :并不是所有的业务逻辑都适合画图。有些控制流用命令式的 if/else 表达远比用 AddBranch 表达更清晰。为了把 ReAct 塞进 Graph 里,adk/react.go 被迫发明了 toolNodeToEndConverter 这种为了迎合框架而存在的"胶水节点"。
    • 最优秀的做法 :理想的框架应该支持**"过程式代码的自动图化"**。开发者写的是带有特定标记的普通 if/for 代码,框架(通过 AST 解析或特殊的 Runtime)自动在底层维护 State 和 Checkpoint。类似于 Temporal 的 Workflow 设计------你写的是普通 Go 代码,但它底层的执行是分布式的状态机。
  3. 缺少原生的"时空旅行"调试器(Time-Travel Debugger)

    • 痛点:既然底层已经是完美的 Graph 和 Checkpoint,为什么开发者在写错逻辑时,还是只能看干瘪的日志?
    • 本质缺陷:Eino 的运行时数据结构非常完备,但缺乏配套的顶级开发者工具。
    • 最优秀的做法 :最优秀的框架(如 LangSmith 试图做的),应该能基于这些 Checkpoint,提供一个图形化界面:允许开发者拖动时间轴,回到第 15 次 ReAct 循环,修改当时的 Prompt,然后点击"从此处继续"。既然底层已经支持了 ResumeWithParams,缺少的就是把这种能力白盒化地暴露给开发者。
结论

Eino 的设计绝不完美 ,它是一个在当前 Go 语言生态下,为了解决企业级复杂问题而做出的重型妥协(Heavy Trade-off)

它用"图"和"泛型"的复杂性,换取了系统运行时的"稳定"和"可恢复"。

未来的进化方向,不应该是把底层图引擎搞得更复杂,而是应该在图引擎之上,发明一种更直觉的编程范式(比如类似 Temporal 的工作流),把画图的脏活从开发者眼前彻底抹去

相关推荐
Book思议-1 小时前
【数据结构考研真题】链表题
c语言·数据结构·算法·链表·408·计算机考研
我的offer在哪里1 小时前
腾讯 Ardot 深度博客:AI 重构 UI/UX 全链路,从 “描述即界面” 到设计工业化的腾讯范式
人工智能·ui·重构
AEIC学术交流中心1 小时前
【快速EI检索 | IEEE出版】第六届信号图像处理与通信国际学术会议(ICSIPC 2026)
图像处理·人工智能
⁤⁢初遇1 小时前
数据结构---排序
数据结构·算法·排序算法
康世行1 小时前
IDEA集成AI辅助工具推荐(好用不卡顿)
java·人工智能·intellij-idea
柯儿的天空1 小时前
【OpenClaw 全面解析:从零到精通】第007篇:流量枢纽——OpenClaw Gateway 网关深度解析
人工智能·gpt·ai作画·gateway·aigc·ai编程·ai写作
人道领域1 小时前
2026年Q1大模型深度复盘:OpenAI,Gemini2.0,字节跳动,与“多模态Agent”元年
人工智能·ai·google·chatgpt·gemini
前端摸鱼匠1 小时前
大模型面试题1:简述大模型(LLM)的定义,与传统NLP模型的核心区别是什么?
人工智能·ai·语言模型·自然语言处理·面试·职场和发展
光锥智能1 小时前
AI风越大,云计算越贵
人工智能·云计算