ReAct 循环的 50 行 Go 实现,逐行拆解

ReAct 循环的 50 行 Go 实现,逐行拆解

系列「企业级 AI Agent 实现拆解」第三篇。上一篇讲了 Session 聚合根和状态机------状态怎么迁移、事件怎么发、终态怎么判。但状态机本身是静态的,谁在驱动这些迁移? 答案是 RunTurnHandler.Handle()------每一轮用户消息进来,它负责加载会话、组装上下文、把 ReAct 循环跑起来、然后落库收尾。


先说一个架构决策:ReAct 循环本身(LLM 推理 → 工具调用 → 观察 → 继续推理)我们没自己写,交给 Einoreact.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 的 chatModelAdaptertoolBrokerAdapter,后者从 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=truerunInput.HistoryrunInput.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.StreamReadereinoadapter 包里被包装成这个接口,这里看不到任何 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 doneemitTerminalEvents 单独控制,避免事务和非事务两条路径重复触发。

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 的正确姿势

相关推荐
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_8:(盒模型完全解)
前端·javascript·css·ui·交互
许彰午2 小时前
从LIKE暴力匹配到LLM智能分类——遗留系统数据分析实战
人工智能·分类·数据分析
光影少年2 小时前
react自定义Hook 写法、规则(只能在组件/自定义Hook内调用)
前端·react.js·掘金·金石计划
纸鸢|2 小时前
边缘计算+AI:设备振动分析与故障诊断技术实践
大数据·人工智能
JieE2122 小时前
手把手带你用虚拟头节点实现单链表,搞定所有边界问题
javascript·算法
瑞华丽PLM2 小时前
国产PLM软件供应商
大数据·人工智能·国产plm·瑞华丽plm·瑞华丽
初心未改HD2 小时前
NLP之GPT生成式模型详解
人工智能·自然语言处理
AI品信智慧数智人2 小时前
当智能语音交互遇上仿真机器人,解锁AI人机交互新范式✨
人工智能·机器人·交互