系列「企业级 AI Agent 实现拆解」E50 篇,Part 10 生产工程篇第八章。上一篇 讲了 Graph 编译期。这篇把 E47--E49 的所有积累用起来:Eino 的
flow/agent/react是怎么用 Graph 搭出 ReAct 循环的,每个设计决定背后的原因是什么。
读完这篇你会知道
- Eino ReAct Agent 的 Graph 结构:2 个节点 + 2 条分支 + 1 个 Lambda
- State 在 Agent 里的作用:消息历史怎么在多轮之间累积
StreamToolCallChecker:为什么流式模式需要单独判断"有没有 tool call"ToolReturnDirectly:某些工具如何跳过 LLM,直接把结果返给用户toolResultCollectorMiddleware:工具结果如何在执行时被"侧路"发送- MessageModifier vs MessageRewriter 的区别
NewAgent里做了什么:从 Config 到一个可调用的Runnable
ReAct 循环是什么
ReAct(Reason + Act)是最常见的 Agent 模式:
用户输入
↓
LLM 思考(Reason)
├─ 有工具调用 → 执行工具(Act)→ 把结果喂回 LLM → 再次思考
└─ 没有工具调用 → 输出最终答案
这个循环的特点是有环 ------LLM 完成之后可能回到 LLM 自身。这就是为什么 E48 要有两种 channel 类型:ReAct 用 Pregel 模式,而不是 DAG。
Eino 把这个循环编码成了一个 Graph:
scss
START → ChatModel → [分支] → Tools → [分支] → ChatModel (循环)
↘ END ↘ END (ReturnDirectly)
Graph 结构:5 个组件
go
// flow/agent/react/react.go
graph := compose.NewGraph[[]*schema.Message, *schema.Message](
compose.WithGenLocalState(func(ctx context.Context) *state {
return &state{Messages: make([]*schema.Message, 0, config.MaxStep+1)}
}),
)
输入是消息列表 []*schema.Message,输出是单条消息 *schema.Message(最终答案)。State 持有完整的对话历史。
节点 1:ChatModel(chat)
go
graph.AddChatModelNode(nodeKeyModel, chatModel,
compose.WithStatePreHandler(modelPreHandle),
compose.WithNodeName(modelNodeName),
)
modelPreHandle 是这个节点的 preProcessor(E48 讲过):
go
modelPreHandle := func(ctx context.Context, input []*schema.Message, state *state) ([]*schema.Message, error) {
state.Messages = append(state.Messages, input...) // 把新消息追加到历史
if config.MessageRewriter != nil {
state.Messages = config.MessageRewriter(ctx, state.Messages) // 先 rewrite
}
if messageModifier == nil {
return state.Messages, nil
}
// modifier 在一个副本上操作,不修改 state
modifiedInput := make([]*schema.Message, len(state.Messages))
copy(modifiedInput, state.Messages)
return messageModifier(ctx, modifiedInput), nil
}
MessageRewriter vs MessageModifier 的区别:
MessageRewriter:直接修改state.Messages,改动会持久保留给下一轮。适合压缩历史(比如超出 context window 时删除旧消息)。MessageModifier:在副本上操作,不影响state.Messages。改动只对当前这次 LLM 调用生效。适合"每次都加 System Prompt"但不想让它出现在历史记录里。
节点 2:ToolsNode(tools)
go
graph.AddToolsNode(nodeKeyTools, toolsNode,
compose.WithStatePreHandler(toolsNodePreHandle),
compose.WithNodeName(toolsNodeName),
)
toolsNodePreHandle:
go
toolsNodePreHandle := func(ctx context.Context, input *schema.Message, state *state) (*schema.Message, error) {
if input == nil {
return state.Messages[len(state.Messages)-1], nil // HITL resume 时 input 为 nil
}
state.Messages = append(state.Messages, input) // 把 LLM 的 tool call 消息记进历史
// 判断哪个工具需要 ReturnDirectly
state.ReturnDirectlyToolCallID = getReturnDirectlyToolCallID(input, config.ToolReturnDirectly)
return input, nil
}
注意 input == nil 的处理:当 Graph 被 HITL 中断后恢复,节点重跑时没有新输入------它从 State 里取最后一条消息来继续,不会重发工具调用。
分支 1:ChatModel 后分支
go
modelPostBranchCondition := func(ctx context.Context, sr *schema.StreamReader[*schema.Message]) (endNode string, err error) {
if isToolCall, err := toolCallChecker(ctx, sr); err != nil {
return "", err
} else if isToolCall {
return nodeKeyTools, nil
}
return compose.END, nil
}
graph.AddBranch(nodeKeyModel,
compose.NewStreamGraphBranch(modelPostBranchCondition,
map[string]bool{nodeKeyTools: true, compose.END: true}))
分支函数接收 流式 的 LLM 输出(StreamReader[*schema.Message]),需要在流没有读完的情况下决定走哪条路。
StreamToolCallChecker:流式模式的难点
非流式情况很简单------msg.ToolCalls 有没有内容一目了然。流式情况下,LLM 输出是逐 Token 推进的,框架必须在不能等完整输出的前提下判断"这次 LLM 是在调工具还是在回答问题"。
Eino 默认的实现:
go
func firstChunkStreamToolCallChecker(_ context.Context, sr *schema.StreamReader[*schema.Message]) (bool, error) {
defer sr.Close()
for {
msg, err := sr.Recv()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, err
}
if len(msg.ToolCalls) > 0 {
return true, nil // 第一个有内容的 chunk 包含 tool call → 走工具分支
}
if len(msg.Content) == 0 {
continue // 跳过空 chunk(流开头可能有几个空 chunk)
}
return false, nil // 第一个有内容的 chunk 是文本 → 走 END
}
}
关键限制 :调用这个 checker 之后,sr 就被消费了(全部读取并 Close)。这意味着:
- checker 必须 把整个 stream 读完再 Close
- 不能把这个 stream 再转发给下游------在判断完之后,Graph 会在下一轮重新拿 LLM 的输出
为什么对 Claude 不适用:OpenAI 的 tool call 出现在流的第一个有内容的 chunk 里(ToolCalls 字段先到);Claude 的流式输出里,文本内容和 tool call 可能交替出现,第一个 chunk 往往是文本。用默认 checker 就会误判为"直接回答"。如果用 Claude,需要自己实现一个消费完整个流再判断的 checker。
分支 2:Tools 后分支 + ReturnDirectly
这是 ReAct 里比较复杂的一段逻辑:
go
// Tools 后的分支:决定是回 ChatModel 还是 ReturnDirectly
graph.AddBranch(nodeKeyTools, compose.NewStreamGraphBranch(
func(ctx context.Context, msgsStream *schema.StreamReader[[]*schema.Message]) (endNode string, err error) {
msgsStream.Close() // 不需要读内容,只看 State
err = compose.ProcessState[*state](ctx, func(_ context.Context, state *state) error {
if len(state.ReturnDirectlyToolCallID) > 0 {
endNode = nodeKeyDirectReturn
} else {
endNode = nodeKeyModel // 正常路径:工具结果回 LLM
}
return nil
})
return endNode, err
},
map[string]bool{nodeKeyModel: true, nodeKeyDirectReturn: true},
))
注意 msgsStream.Close() 立刻就被调用了------这个分支函数只需要读 State 里的标志位,不需要工具输出的内容。
ReturnDirectly Lambda 节点
go
// 从所有工具结果里,找到那个需要直接返回的工具结果
directReturn := func(ctx context.Context, msgs *schema.StreamReader[[]*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderWithConvert(msgs, func(msgs []*schema.Message) (*schema.Message, error) {
var msg *schema.Message
compose.ProcessState[*state](ctx, func(_ context.Context, state *state) error {
for i := range msgs {
if msgs[i] != nil && msgs[i].ToolCallID == state.ReturnDirectlyToolCallID {
msg = msgs[i]
return nil
}
}
return nil
})
if msg == nil {
return nil, schema.ErrNoValue // 过滤掉不需要直接返回的工具结果
}
return msg, nil
}), nil
}
ErrNoValue(E47 讲过的流过滤哨兵)在这里派上了用场:当工具结果列表里没有匹配的 ToolCallID,返回 ErrNoValue 就自动跳过这条,不会错误输出。
两种触发 ReturnDirectly 的方式
- 配置级 :
AgentConfig.ToolReturnDirectly = map[string]struct{}{"final_answer_tool": {}}------某个工具一旦被调用,就直接返回结果 - 运行时 :工具内部调用
react.SetReturnDirectly(ctx)
go
func SetReturnDirectly(ctx context.Context) error {
return compose.ProcessState(ctx, func(ctx context.Context, s *state) error {
s.ReturnDirectlyToolCallID = compose.GetToolCallID(ctx) // 从 ctx 取当前工具的 CallID
return nil
})
}
这让工具本身可以根据自己的执行结果决定"这次要直接返回",更灵活。
toolResultCollectorMiddleware:工具结果的侧路发送
go
func newToolResultCollectorMiddleware() compose.ToolMiddleware {
return compose.ToolMiddleware{
Invokable: func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
senders := getToolResultSendersFromCtx(ctx)
output, err := next(ctx, input) // 先执行原始工具
if err != nil {
return nil, err
}
if senders != nil && senders.sender != nil {
senders.sender(input.Name, input.CallID, output.Result) // 侧路发送
}
return output, nil
}
},
// Streamable / Enhanced 版本类似,流式的话先 Copy(2)
Streamable: func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
senders := getToolResultSendersFromCtx(ctx)
output, err := next(ctx, input)
if err != nil {
return nil, err
}
if senders != nil && senders.streamSender != nil {
streams := output.Result.Copy(2) // fan-out:一份给 sender,一份继续
senders.streamSender(input.Name, input.CallID, streams[0])
output.Result = streams[1]
}
return output, nil
}
},
}
}
这个中间件自动被塞在所有工具的最前面:
go
config.ToolsConfig.ToolCallMiddlewares = append(
[]compose.ToolMiddleware{newToolResultCollectorMiddleware()},
config.ToolsConfig.ToolCallMiddlewares...,
)
getToolResultSendersFromCtx(ctx) 从 context 里取 sender------调用方可以在 ctx 里注入 sender,这样工具结果就能实时推送到 SSE 流(DeepFlux 就是这么做的:工具执行时实时推送进度给前端,不用等所有工具跑完)。
流式工具的 Copy(2) 正是 E47 讲过的 fan-out:一份推给 sender,一份继续走 Graph 正常流程。
NewAgent 组装全流程
scss
NewAgent(ctx, config)
│
├─ genToolInfos → 收集所有工具的 ToolInfo(名字、描述、schema)
├─ ChatModelWithTools → 把工具 schema 注入 LLM 配置
├─ NewToolNode → 创建 ToolsNode
│
├─ compose.NewGraph → 带 State 的 Graph(state = 消息历史)
├─ AddChatModelNode → 含 modelPreHandle(累积消息历史)
├─ AddEdge(START, chat)
├─ AddToolsNode → 含 toolsNodePreHandle(记录工具调用消息)
├─ AddBranch(chat, ...)→ StreamToolCallChecker 决定 tools or END
├─ buildReturnDirectly
│ ├─ AddLambdaNode("direct_return", ...) → 过滤出需要直接返回的工具结果
│ ├─ AddBranch(tools, ...) → 看 State 决定 chatModel or direct_return
│ └─ AddEdge(direct_return, END)
│
└─ graph.Compile(ctx,
WithMaxRunSteps(config.MaxStep),
WithNodeTriggerMode(AnyPredecessor), ← Pregel 模式(有环)
WithGraphName(graphName))
AnyPredecessor 是 Pregel 模式(E48 里讲过),这里显式声明了------ReAct 的循环边(tools → chat)必须用 Pregel,DAG 会因为有环而报错。
最终 Graph 结构图
css
┌──────────────────────────────────────────┐
│ Pregel Graph │
│ │
START ──► │ ChatModel ──► [branch] │
│ │ tool calls │
│ ▼ │
│ ToolsNode ──► [branch] │
│ │ ReturnDirectly
│ ▼ │
│ direct_return ──►│ END
│ │ normal │
│ └──────────────► ChatModel │
└──────────────────────────────────────────┘
▲
cycle
完整调用示例
go
agent, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: llm,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{searchTool, calcTool},
},
MessageModifier: react.NewPersonaModifier("You are a helpful assistant."),
MaxStep: 20,
ToolReturnDirectly: map[string]struct{}{
"final_answer": {}, // 这个工具被调用后直接返回,不再进 LLM
},
})
// 非流式
msg, err := agent.Generate(ctx, []*schema.Message{
schema.UserMessage("帮我搜索一下 Eino 的 GitHub star 数"),
})
// 流式
stream, err := agent.Stream(ctx, []*schema.Message{
schema.UserMessage("..."),
})
for {
chunk, err := stream.Recv()
if err == io.EOF { break }
fmt.Print(chunk.Content)
}
defer stream.Close()
小结
Eino ReAct Agent 不是凭空写的------它是在 E47/E48/E49 讲过的所有机制上搭起来的:
| 用到的机制 | 在 Agent 里的体现 |
|---|---|
| Pregel Channel(E48) | 支持 tools → chat 的循环边 |
| State(E48/E49) | 消息历史在多轮之间累积,不在节点间传递 |
| preProcessor(E48) | modelPreHandle / toolsNodePreHandle |
| StreamReader.Copy(E47) | 流式工具结果 fan-out:一份侧路发给 sender |
| ErrNoValue(E47) | directReturn Lambda 过滤不需要返回的工具结果 |
| Compile AnyPredecessor(E49) | 显式声明 Pregel 模式,支持有环图 |
| ToolMiddleware | 拦截工具执行,侧路推送进度 |
390 行代码实现了一个生产可用的 ReAct Agent------因为基础设施都在 Graph 引擎里,业务逻辑只需要描述"怎么连"。