拨开迷雾看本质:从零推导一个现代 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 退出函数,那么当前的 history 和 i(循环次数)就全部丢失了。等用户 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
你可能会问:第一步我们搞出了 StreamNode 和 Pipe,第二步搞出了 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
}
这样,我们就把 "流式展示" 和 "离线状态" 完美结合了:
- 前端通过监听
outchannel 实现了打字机效果。 - 后端通过
SaveToDB(state)实现了节点级别的持久化。如果下一个节点(Tool)被挂起,我们随时可以从数据库捞出ReActState,并根据state.Next == "Tool"继续往下跑。
5. 第三步演进:应对复杂的控制流与切面(Branch & Middleware)
问题场景 :
随着业务变复杂,你的需求增加了:
- 有些工具(比如
TransferToAgent或Exit)执行完后,不需要把结果喂回给模型,而是直接退出整个 ReAct 循环。 - 每次调模型前,需要把
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
}
通过这种改造:
- 流被完美对接了 :图引擎的
RunStream负责把节点的流输出到前端。 - 状态被隔离了 :分支决断(
BranchFunc)不再依赖偷窥流里的数据,而是等待流结束、节点把数据写回State后,再基于静态的State做出优雅的判断。 - 扩展性极强 :当你需要新增一个"直接退出的工具"(比如
Exit)时,你只需要在图上改一下 Branch 函数,而完全不需要去碰核心的执行引擎和流处理逻辑。这就是 Eino 走向 Graph 编排的终极原因。
6. 映射到现实:Eino 的 adk/react.go 到底做了什么?
当我们带着这套"本质推导"去审视真实的 adk/react.go 时,你会发现它比我们的推导又多了一层抽象。
我们的推导是:手写循环 -> 抽离流(Pipe) -> 抽离状态机(State) -> 抽离图引擎(Graph)。
在我们的推导里,业务开发者依然需要自己去 BuildReActGraph(),自己去画这三四个节点的图。
而 Eino adk/react.go 的本质是:一个高度封装的图生成器(Graph Builder)。
- 它固化了拓扑结构 :
newReact函数(L340)内部写死了Init -> ChatModel <-> ToolNode的图结构。这意味着,使用 ADK 的开发者连图都不需要画了。ADK 认为:"所有的 ReAct 逻辑,本质拓扑都是这张图"。 - 它把图的灵活性转化为了配置项 :既然图结构写死了,业务怎么扩展?ADK 的解法是提供配置(如
config.toolsReturnDirectly)。如果配置了这个项,newReact就会在构建图时,自动在内部多插入一个toolNodeToEndConverter节点和一段Branch(L430-L469)。 - 它隐藏了 State 的脏活 :在我们的推导里,Model 节点需要自己把结果 append 到 State 里。但在 Eino 中,这些动作被拆分成了钩子函数。比如
modelPreHandle(L364)用来扣减迭代次数,toolPreHandle(L374)用来在调用工具前从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 做的极好的地方(已经触及本质):
- 彻底解决了"流与状态"的冲突 :通过
compose层的StreamReader和ProcessState,Eino 几乎是目前开源界把 Streaming 和 Checkpoint 结合得最深、最优雅的框架。它没有像 LangChain 那样在 Python 的异步地狱里挣扎,而是用强类型的 Graph 状态机把这俩"水火不容"的东西缝合了。 - 多层级的降级逃生舱 :Eino 并没有只提供 ADK。如果你觉得
adk/react.go太死板,你可以降级去用compose手画图;如果觉得画图太重,你可以直接调components/model。这种分层设计(Component -> Compose -> ADK)是优秀的。
Eino 缺乏什么?(距离"最优秀"还有多远):
尽管底层引擎很强,但从开发者体验(DX)的本质来看,Eino 依然有明显的痛点:
-
类型体操的过度负担(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)。
- 痛点 :为了在强类型的 Go 语言中实现通用的 Graph,Eino 引入了大量的泛型、
-
控制流的过度"图化"(Over-Graphification)
- 痛点 :Eino 强制要求所有的控制流(比如
ReturnDirectly直接返回)都必须翻译成图的分支(Branch)和节点(Node)。 - 本质缺陷 :并不是所有的业务逻辑都适合画图。有些控制流用命令式的
if/else表达远比用AddBranch表达更清晰。为了把 ReAct 塞进 Graph 里,adk/react.go被迫发明了toolNodeToEndConverter这种为了迎合框架而存在的"胶水节点"。 - 最优秀的做法 :理想的框架应该支持**"过程式代码的自动图化"**。开发者写的是带有特定标记的普通
if/for代码,框架(通过 AST 解析或特殊的 Runtime)自动在底层维护 State 和 Checkpoint。类似于 Temporal 的 Workflow 设计------你写的是普通 Go 代码,但它底层的执行是分布式的状态机。
- 痛点 :Eino 强制要求所有的控制流(比如
-
缺少原生的"时空旅行"调试器(Time-Travel Debugger)
- 痛点:既然底层已经是完美的 Graph 和 Checkpoint,为什么开发者在写错逻辑时,还是只能看干瘪的日志?
- 本质缺陷:Eino 的运行时数据结构非常完备,但缺乏配套的顶级开发者工具。
- 最优秀的做法 :最优秀的框架(如 LangSmith 试图做的),应该能基于这些 Checkpoint,提供一个图形化界面:允许开发者拖动时间轴,回到第 15 次 ReAct 循环,修改当时的 Prompt,然后点击"从此处继续"。既然底层已经支持了
ResumeWithParams,缺少的就是把这种能力白盒化地暴露给开发者。
结论
Eino 的设计绝不完美 ,它是一个在当前 Go 语言生态下,为了解决企业级复杂问题而做出的重型妥协(Heavy Trade-off) 。
它用"图"和"泛型"的复杂性,换取了系统运行时的"稳定"和"可恢复"。
未来的进化方向,不应该是把底层图引擎搞得更复杂,而是应该在图引擎之上,发明一种更直觉的编程范式(比如类似 Temporal 的工作流),把画图的脏活从开发者眼前彻底抹去。