ReAct 循环的 50 行 Go 实现,逐行拆解
系列「企业级 AI Agent 实现拆解」第三篇。上一篇讲了 Session 聚合根和状态机------状态怎么迁移、事件怎么发、终态怎么判。但状态机本身是静态的,谁在驱动这些迁移? 答案是
RunTurnHandler.Handle()------每一轮用户消息进来,它负责加载会话、组装上下文、把 ReAct 循环跑起来、然后落库收尾。
先说一个架构决策:ReAct 循环本身(LLM 推理 → 工具调用 → 观察 → 继续推理)我们没自己写,交给 Eino 的 react.NewAgent() 跑。Handle() 只做框架解决不了的部分:Session 状态迁移、多租户 context 注入、长期记忆召回、事务落库、异步记忆提炼。
下面是去掉错误处理样板代码后的主干,大约 50 行:
go
func (h *RunTurnHandler) Handle(ctx context.Context, in RunTurnInput) error {
// ① 加载会话,校验状态
sess, _ := h.repo.Load(ctx, in.SessionID)
if sess.IsTerminal() { return errors.New("session already terminal") }
// ② context 注入
ctx = context.WithValue(ctx, port.ContextKeyAgentTenantID{}, sess.TenantID())
ctx = context.WithValue(ctx, port.ContextKeyAgentUserID{}, sess.UserID())
ctx = context.WithValue(ctx, port.ContextKeyAgentSessionID{}, string(sess.ID()))
// ③ 前置 hook + 加载配置
h.invokeHook(func(hr port.HookRunner) { hr.BeforeSession(ctx, sess.ID()) })
cfg, _ := h.configs.Load(ctx, sess.TenantID(), sess.AgentConfig())
// ④ 判断路径:新 turn 还是 HITL 恢复
isResume := in.UserText == ""
runInput := port.RunStreamInput{IsResume: isResume}
var capturedHistory []model.Message
if isResume {
// ⑤a HITL 恢复:状态迁移 WAITING→RUNNING,先落库再跑
sess.Resume(*in.Decision)
h.persist(ctx, sess)
} else {
// ⑤b 新 turn:拉历史、写 user 消息、召回记忆、拼 system prompt
history, _ := h.repo.ListMessages(ctx, sess.ID())
sess.IncTurn()
userMsg := model.NewUserMessage(sess.ID(), sess.TurnCount(), in.UserText)
h.repo.AppendMessage(ctx, userMsg)
history = append(history, userMsg)
capturedHistory = history
memCtx := h.recallMemory(ctx, sess.TenantID(), sess.UserID(), in.UserText)
runInput.SystemContent = expandSystemPrompt(cfg.SystemPrompt, sess) + memCtx
runInput.History = history
}
// ⑥ 交给 Eino:ReAct 循环、工具调用、Hook callback 全在里面
tr, interrupt, err := h.runnableFac.StreamTurn(
ctx, cfg, sess.TenantID(), runInput, h.hooks, h.stream, sess.ID(),
)
if err != nil { return h.failSession(ctx, sess, ...) }
// ⑦ 处理 HITL 中断
if interrupt != nil { return h.handleEinoInterrupt(ctx, sess, interrupt) }
defer tr.Close()
// ⑧ 消费流:token → SSE turn.delta + 收集 finalContent
finalContent, _ := h.consumeEinoStream(ctx, sess.ID(), tr)
// ⑨ 落库 + 触发终态事件 + 异步记忆提炼
asstMsg := model.NewAssistantMessage(sess.ID(), sess.TurnCount(), finalContent, nil)
sess.Complete()
h.commitFinalAnswer(ctx, sess, asstMsg)
if h.memory != nil && !isResume && len(capturedHistory) > 0 {
h.scheduleMemoryExtraction(sess, capturedHistory, finalContent)
}
return nil
}
接下来逐块说每一段在做什么,以及为什么这样设计。
① 加载会话,校验状态
go
sess, err := h.repo.Load(ctx, in.SessionID)
if err != nil {
return h.onError(ctx, in.SessionID, err)
}
if sess.IsTerminal() {
return errors.New("session already terminal")
}
终态(COMPLETED / FAILED / CANCELLED)的 Session 不可以再跑新 Turn,这是状态机的硬约束。在入口做这个检查,比在循环里做更干净。
② context 注入
为什么用 context.WithValue 而不是 struct 字段?因为 Runnable 按 AgentConfig 维度缓存,跨 session 复用------如果 sessionID 绑到 struct 字段上,缓存完第一个 session 后就错了。从 context 动态读,缓存才能安全共享。
go
ctx = context.WithValue(ctx, port.ContextKeyAgentTenantID{}, sess.TenantID())
ctx = context.WithValue(ctx, port.ContextKeyAgentUserID{}, sess.UserID())
ctx = context.WithValue(ctx, port.ContextKeyAgentSessionID{}, string(sess.ID()))
这三个值随 context 一路传进 Eino 的 chatModelAdapter 和 toolBrokerAdapter,后者从 context 取出来做租户隔离和工具路由。
③ 前置 hook + 加载配置
go
h.invokeHook(func(hr port.HookRunner) { _ = hr.BeforeSession(ctx, sess.ID()) })
cfg, err := h.configs.Load(ctx, sess.TenantID(), sess.AgentConfig())
BeforeSession 是 Hook 链的第一个阶段,典型用途是认证验证、配额预检、tracing 初始化。invokeHook 做了 nil 检查,没有注入 HookRunner 时静默跳过。
AgentConfig 包含 system prompt 模板、LLM profile、工具白名单、MaxTurns、HITL 开关等,每次 Turn 都重新加载,配置变更即时生效。
④ 判断路径
go
isResume := in.UserText == ""
一个布尔值区分两条路径:UserText == "" 表示 HITL 恢复(从 Eino checkpoint 继续),否则是新 Turn。两条路径的准备工作不同,但最终都走同一个 StreamTurn 调用。
⑤a HITL 恢复路径
go
if sess.State() != model.StateWaiting {
return fmt.Errorf("resume called but session not waiting, state=%s", sess.State())
}
dec := model.InterruptDecision{Action: model.DecisionApprove}
if in.Decision != nil {
dec = *in.Decision
}
if err := sess.Resume(dec); err != nil {
_ = h.onError(ctx, sess.ID(), err)
return err
}
if err := h.persist(ctx, sess); err != nil {
return err
}
先落库,再跑 。sess.Resume(dec) 把状态从 WAITING 迁移到 RUNNING,立刻写库。这样进程崩溃重启时,session 已经是 RUNNING,重试逻辑可以正确处理。如果先跑再落库,崩溃后 session 还是 WAITING,重试会重新走恢复路径,可能重复执行。
isResume=true 时 runInput.History 和 runInput.SystemContent 保持零值------Eino 收到 nil inputMsgs 时,自动从 CheckpointStore 加载上次暂停时的图状态。
⑤b 新 turn 路径
go
history, _ := h.repo.ListMessages(ctx, sess.ID())
sess.IncTurn()
userMsg := model.NewUserMessage(sess.ID(), sess.TurnCount(), in.UserText)
h.repo.AppendMessage(ctx, userMsg)
history = append(history, userMsg)
capturedHistory = history
memCtx := h.recallMemory(ctx, sess.TenantID(), sess.UserID(), in.UserText)
runInput.SystemContent = expandSystemPrompt(cfg.SystemPrompt, sess) + memCtx
runInput.History = history
五件事按顺序做:拉历史 → 增轮次计数 → 写 user 消息 → 召回长期记忆 → 组装 system prompt。
recallMemory 用本轮用户文本检索相关记忆片段,追加到 system prompt 末尾,失败时静默返回空字符串。expandSystemPrompt 展开 {``{user_id}}、{``{tenant_id}}、{``{time}} 三个占位符。capturedHistory 留着会话结束后给记忆提炼用。
⑥ 交给 Eino
go
tr, interrupt, err := h.runnableFac.StreamTurn(
ctx, cfg, sess.TenantID(), runInput, h.hooks, h.stream, sess.ID(),
)
这一行是整个 Handle 的边界线:左边是 DDD 领域逻辑,右边是 Eino 框架内部。为什么切在这里,而不是更早(比如让 Eino 接管整个 Handle)或更晚(手写 ReAct for 循环)?因为状态加载、context 注入、记忆召回这些事和 LLM 编排无关,框架不该管;而 LLM 调用、工具分发、流式输出这些事已经有成熟实现,没必要重写。这条边界让两边各自演进------换 Eino 版本不影响状态机,改记忆策略不影响 Runnable 缓存。
StreamTurn 内部:查 Runnable 缓存(TTL 35 分钟)→ 未命中则 react.NewAgent() 构建并编译 → 设置 compose 选项(checkpoint ID、callbacks、force-new-run)→ runnable.Stream() → 检测 interrupt 信号。应用层完全不感知这些。
返回三种情况,含义清晰:
| 返回值 | 含义 |
|---|---|
(TokenReader, nil, nil) |
正常流,消费到 Final Answer |
(nil, *AgentInterruptInfo, nil) |
Eino 图级 HITL 中断 |
(nil, nil, err) |
执行错误 |
⑦ 处理 HITL 中断
go
if interrupt != nil {
it := model.NewPreToolInterrupt("hitl: tool call requires approval", model.ToolCall{}, h.interruptTTL)
sess.Pause(it)
h.stream.Emit(ctx, sess.ID(), port.StreamEvent{
Type: "interrupt", Payload: map[string]any{"before_nodes": info.BeforeNodes},
})
return h.persist(ctx, sess)
}
中断信号来自 Eino 图级的 WithInterruptBeforeNodes(["tools"]),不是 Hook 返回值。Hook 的 BeforeToolUse 在 Eino 的 Callback 层执行,应用层看到的是 Eino 已经决定了"要中断",只需要把 Session 状态迁移到 WAITING 并推 SSE 通知前端。
⑧ 消费流
go
var sb strings.Builder
for {
content, err := tr.Recv()
if errors.Is(err, io.EOF) { break }
if err != nil { return sb.String(), err }
if content != "" {
sb.WriteString(content)
h.stream.Emit(ctx, sid, port.StreamEvent{Type: "turn.delta", Payload: content})
}
}
port.TokenReader 是框架无关接口(Recv() (string, error))。Eino 的 schema.StreamReader 在 einoadapter 包里被包装成这个接口,这里看不到任何 Eino 类型。LLM 吐一个 token,SSE 就推一帧,没有额外 goroutine,没有缓冲区。
⑨ 落库、触发终态、异步记忆提炼
go
asstMsg := model.NewAssistantMessage(sess.ID(), sess.TurnCount(), finalContent, nil)
sess.Complete()
h.commitFinalAnswer(ctx, sess, asstMsg)
if h.memory != nil && !isResume && len(capturedHistory) > 0 {
h.scheduleMemoryExtraction(sess, capturedHistory, finalContent)
}
三件事:
commitFinalAnswer :优先走事务路径,消息写入和 session 状态更新在同一个数据库事务里。写完后 emitTerminalEvents 触发 AfterSession hook 和 SSE done 帧。注意 persist() 和 emitTerminalEvents() 是两个独立函数------persist 只写库发事件,SSE done 由 emitTerminalEvents 单独控制,避免事务和非事务两条路径重复触发。
scheduleMemoryExtraction :起一个 goroutine,30 秒超时,把本轮 user/assistant 消息异步发给 memory BC 提炼记忆。失败只触发 OnError hook,不阻塞响应返回。HITL 恢复路径(isResume=true)跳过,避免同一会话重复提炼。
流程图
Handle(in)
│
├─ Load + IsTerminal 检查
├─ context 注入(tenantID/userID/sessionID)
├─ BeforeSession hook + Load AgentConfig
│
├─── isResume=true ──────────────────────────────────────┐
│ StateWaiting 校验 │
│ sess.Resume → persist(先落库) │
│ runInput: IsResume=true, History=nil │
│ │
└─── isResume=false ─────────────────────────────────────┤
ListMessages + IncTurn + AppendMessage │
recallMemory + expandSystemPrompt │
runInput: SystemContent + History │
↓
runnableFac.StreamTurn(...)
┌── Eino 内部 ──────────────┐
│ react.NewAgent ReAct 循环│
│ 工具调用 + Hook callback │
└──────────────────────────┘
│
┌─────────────┼──────────────┐
interrupt TokenReader error
│ │ │
sess.Pause() consumeEinoStream failSession
SSE interrupt SSE turn.delta
persist 收集 finalContent
│
sess.Complete()
commitFinalAnswer(事务)
emitTerminalEvents → SSE done
scheduleMemoryExtraction(goroutine)
小结
回头看这 50 行主干,Handle() 的核心职责不是"跑 ReAct 循环"------那件事 Eino 做了。它的真正工作是做好框架不管的事:Session 状态迁移的原子性、多租户 context 的透传、记忆的召回和异步提炼、事务落库和终态事件解耦。这些事情没有框架能替你做,因为它们和具体业务域强绑定。
改 Eino 版本不影响状态机,改记忆策略不影响 Runnable 缓存,改 Hook 不影响事务落库------这条边界线的价值就在这里。
下一篇看 HITL 完整路径:从 Eino 图级中断到人工审批,再到 checkpoint 恢复执行。
下一篇:HITL 中断 ------ 让人类随时叫停 AI 的正确姿势