犀牛鸟 2026 研究笔记 estelledc.github.io

第三章:tRPC-Agent-Go ReAct 循环与 LLM Flow 深度解析

本章目标:深入理解 LLM Flow 的 2468 行核心代码,搞清楚 ReAct 循环的每一步在做什么,以及 tRPC-Agent 对经典 ReAct 做了哪些生产级增强。


3.1 ReAct 模式:Agent 思考的基本范式

在深入代码之前,先彻底理解 ReAct(Reasoning + Acting)这个模式。

经典 ReAct 的三步循环

Think(推理): LLM 分析当前情况,决定下一步做什么
  ↓
Act(行动): 执行 LLM 决定的工具调用
  ↓
Observe(观察): 把工具返回的结果告诉 LLM
  ↓
回到 Think,直到 LLM 决定给出最终答案

日常类比:你在修一台不出水的洗衣机。

  1. Think:水龙头开了吗?可能是进水阀堵了。先检查进水管。
  2. Act:拔下进水管,看看有没有水流出来。
  3. Observe:有水,说明不是水源问题。是进水阀本身坏了。
  4. Think:需要更换进水阀。先查一下型号。
  5. Act:拍照阀门上的标签,搜索型号。
  6. Observe:型号是 XYZ-123,淘宝有卖。
  7. Final Answer:需要购买 XYZ-123 型号进水阀更换。

Agent 的每一轮”Think→Act→Observe”就是上面这个过程——只不过 Think 是 LLM 做的,Act 是调用工具做的。


3.2 LLM Flow 的核心循环

internal/flow/llmflow/llmflow.go 是 tRPC-Agent-Go 的心脏。核心循环(简化版):

func (f *Flow) Run(ctx context.Context, invocation *agent.Invocation) (<-chan *event.Event, error) {
    eventChan := make(chan *event.Event, f.channelBufferSize)

    go func(ctx context.Context) {
        defer close(eventChan)

        // 可能从挂起的 tool call 恢复(断点续跑)
        f.maybeResumePendingToolCalls(ctx, invocation, eventChan)

        for {
            // === 循环入口 ===

            // 1. 发射 "开始新一轮" 事件
            f.emitStartEventAndWait(ctx, invocation, eventChan)

            // 2. 可能消费队列中的用户插入消息(Steer 功能)
            f.maybeConsumeQueuedUserMessages(ctx, invocation, eventChan)

            // 3. 执行一步完整的 Think→Act→Observe
            lastEvent, err := f.runOneStep(ctx, invocation, eventChan)

            // === 退出条件 ===
            if lastEvent == nil {
                break  // 出错或被取消
            }
            if invocation.EndInvocation {
                break  // Agent 主动标记结束
            }
            if lastEvent.IsFinalResponse() {
                break  // LLM 给出了最终答案(无 tool_call)
            }

            // 否则继续循环
        }
    }(runCtx)

    return eventChan, nil
}

3.3 runOneStep:一次完整的 Think→Act→Observe

runOneStep 是单轮 ReAct 的实现,分为三个阶段:

阶段一:Preprocess(构建 LLM 请求)

// 伪代码
func (f *Flow) runOneStep(ctx, inv, eventChan) (*event.Event, error) {
    // 1. 构建 LLM 请求
    llmRequest := f.buildLLMRequest(inv)
    // buildLLMRequest 内部做的事:
    //   - 注入系统指令(system prompt)
    //   - 注入 session 历史消息(让 LLM 知道之前聊了什么)
    //   - 注入可用工具列表(让 LLM 知道能调什么)
    //   - 应用 requestProcessors 链(自定义预处理)

requestProcessors 链是一个扩展点。你可以注入自定义的处理器来修改 LLM 请求。比如:

阶段二:Call LLM(调用大模型)

    // 2. 调用 LLM(支持流式响应)
    responseChan := f.callLLM(ctx, inv, llmRequest, callModel)

    // 流式处理 LLM 的响应 token
    var fullResponse *LLMResponse
    for chunk := range responseChan {
        eventChan <- &event.Event{Type: "streaming", Data: chunk}
        fullResponse.Append(chunk)
    }

关键细节:LLM 调用是流式的(streaming)。LLM 一个 token 一个 token 地返回,Flow 收到一个就发射一个事件。这就是用户端能看到”打字机效果”的原因。

阶段三:Process Response(处理响应)

    // 3. 分析 LLM 的完整响应
    if fullResponse.HasToolCalls() {
        // LLM 决定调用工具
        for _, toolCall := range fullResponse.ToolCalls {
            // 执行工具
            result, err := f.executeTool(ctx, inv, toolCall)

            // 把工具调用和结果追加到 session(下次循环 LLM 就能看到)
            inv.Session.Append(toolCallMessage)
            inv.Session.Append(toolResultMessage)

            // 发射事件
            eventChan <- &event.Event{Type: "tool_call", Data: toolCall}
            eventChan <- &event.Event{Type: "tool_result", Data: result}
        }
        return lastToolEvent, nil  // 返回非 nil、非 final → 循环继续
    }

    // LLM 没有调用工具 → 这就是最终答案
    finalEvent := &event.Event{Type: "final_response", Data: fullResponse.Text}
    eventChan <- finalEvent
    return finalEvent, nil  // IsFinalResponse() == true → 循环退出
}

3.4 循环退出条件详解

LLM Flow 在三种情况下退出循环:

条件 含义 触发方式
lastEvent == nil 执行出错或被取消 ctx.Done() 或 LLM 调用失败
invocation.EndInvocation == true Agent 主动标记结束 某个工具执行后设置了此标志
lastEvent.IsFinalResponse() LLM 给出了最终答案 LLM 的响应中没有 tool_call

第三种是最常见的退出方式:当 LLM 判断已经收集够了信息、可以回答用户问题时,它不再调用任何工具,直接给出文本答案——这就是 final response。

重要限制:还有一个隐式退出条件——MaxSteps。如果循环次数超过 invocation.MaxSteps(默认通常是 25),Flow 会强制退出。这是防止 Agent 陷入无限循环的安全网。


3.5 生产级增强:超越经典 ReAct

tRPC-Agent-Go 的 LLM Flow 不是教科书上的简单 ReAct——它做了多项生产级增强:

增强一:断点恢复(maybeResumePendingToolCalls)

// 循环开始前,检查是否有未完成的 tool call
f.maybeResumePendingToolCalls(ctx, invocation, eventChan)

场景:Agent 正在执行一个耗时工具(比如编译大项目),中途服务重启了。重启后 Flow 检查 session 中有没有”已发起但未完成”的 tool call,如果有,恢复执行而非从头开始。

日常类比:你做饭到一半停电了。来电后你不需要重新洗菜切菜——看看锅里已经有什么,继续做就行。

增强二:用户中途插入消息(Steer)

// 每轮循环开始时,检查用户是否插入了新消息
f.maybeConsumeQueuedUserMessages(ctx, invocation, eventChan)

场景:Agent 正在执行多步任务,用户中途说”等一下,方向错了,改成搜中文资料”。这条消息会被插入到下一轮 LLM 请求中,改变 Agent 的执行方向。

这就是 Runner 层 SteerableRunner 接口的意义——允许用户在 Agent 执行过程中”转向”。

增强三:并行工具执行

如果 LLM 一次返回多个 tool_call(比如同时搜索三个关键词),Flow 可以并行执行它们而非串行等待:

if len(toolCalls) > 1 && f.parallelToolExecution {
    results := f.executeToolsInParallel(ctx, inv, toolCalls)
} else {
    results := f.executeToolsSequentially(ctx, inv, toolCalls)
}

增强四:工具执行超时和重试

result, err := f.executeTool(ctx, inv, toolCall)
if err != nil && f.toolRetryPolicy.ShouldRetry(err) {
    // 重试逻辑:指数退避 + 最大重试次数
    result, err = f.retryTool(ctx, inv, toolCall)
}

增强五:流式事件发射

经典 ReAct 的实现通常是”等 LLM 说完了再处理”。tRPC-Agent 的 Flow 是流式的——LLM 每吐出一个 token 就发射一个事件,用户端可以实时看到 Agent 的思考过程。


3.6 requestProcessors 链:请求的装配线

在构建 LLM 请求时,Flow 会依次执行一系列 requestProcessor:

type RequestProcessor interface {
    Process(ctx context.Context, inv *Invocation, req *LLMRequest) *LLMRequest
}

// 标准 processors(按顺序执行):
processors := []RequestProcessor{
    &SystemInstructionProcessor{},   // 注入系统指令
    &SessionHistoryProcessor{},      // 注入历史消息
    &ToolsInjectionProcessor{},      // 注入可用工具
    &PlannerProcessor{},             // 如果有 Planner,注入计划
    &ContextWindowProcessor{},       // 截断超出 context window 的历史
    // ...用户自定义 processors
}

for _, p := range processors {
    request = p.Process(ctx, inv, request)
}

这是一个责任链模式——每个 processor 只做一件事,把自己关心的部分加到请求里,然后传给下一个。这让系统高度可扩展:想加一个”根据时间自动调整温度”的逻辑?写一个 processor 插入链即可。


3.7 与 LangGraph 执行模型的深层对比

维度 tRPC-Agent LLM Flow LangGraph Pregel
循环粒度 一次 LLM 调用 = 一轮 一个超步 = 一批并行节点
状态传递 Session 内存(追加消息) Channel(reducer 合并)
并发模型 单 Agent 内串行(工具可并行) 超步内节点并行
流式输出 原生支持(token 级) 超步完成后才输出
中断恢复 Session 持久化 + pending tool call 恢复 Checkpoint 完整快照 O(1) 恢复
人类插入 SteerableRunner 中途注入消息 GraphInterrupt + Command(resume)
退出控制 MaxSteps + EndInvocation + Final Response 到达 END 节点 / GraphInterrupt

关键差异

LangGraph 的 Pregel 引擎把”并行”作为一等公民——同一超步内的多个节点可以并行执行,且有 Channel 系统保证并发写入的一致性。这适合”多个独立子任务同时推进”的工作流。

tRPC-Agent 的 LLM Flow 把”流式”作为一等公民——LLM 每产出一个 token 就能即时推送给用户。这适合”用户在等待、需要实时反馈”的交互场景。

两者不矛盾——tRPC-Agent 的 StateGraph 也能做并行节点,LangGraph 也能做流式输出(astream_events)。但各自的默认路径优化方向不同。


3.8 Debug 技巧:如何跟踪 ReAct 循环

如果你要调试 tRPC-Agent 的 Agent 行为,关注这几个关键事件:

Event: start        → 新一轮循环开始
Event: llm_request  → 发给 LLM 的完整请求(含历史、工具列表)
Event: streaming    → LLM 流式输出的每个 chunk
Event: tool_call    → LLM 决定调用某个工具
Event: tool_result  → 工具执行返回的结果
Event: final_response → LLM 给出最终答案
Event: error        → 出错了

调试时最有价值的是 llm_request 事件——它包含了 LLM”看到”的完整上下文。如果 Agent 行为不符合预期,90% 的问题都可以通过检查这个请求来定位:


3.9 极端情况处理

LLM Flow 需要处理多种极端情况:

极端一:LLM 返回了不存在的工具名

if tool := inv.FindTool(toolCall.Name); tool == nil {
    // 工具不存在,把错误信息告诉 LLM,让它重新选择
    errorMsg := fmt.Sprintf("工具 %s 不存在,可用工具:%v", toolCall.Name, toolNames)
    inv.Session.Append(errorMessage(errorMsg))
    continue  // 不退出循环,让 LLM 重试
}

极端二:工具执行超时

toolCtx, cancel := context.WithTimeout(ctx, f.toolTimeout)
defer cancel()
result, err := tool.Execute(toolCtx, toolCall.Args)
if err == context.DeadlineExceeded {
    // 超时,告诉 LLM 工具执行超时
    inv.Session.Append(timeoutMessage(toolCall.Name))
}

极端三:LLM 陷入死循环(反复调用同一工具)

if f.detectRepetitiveToolCalls(inv.Session) {
    // 检测到重复工具调用模式
    inv.Session.Append(systemMessage("你似乎在重复执行相同操作。请分析已有结果,给出最终答案。"))
}

极端四:Context Window 快满了

if f.estimateTokenCount(inv.Session.Messages) > f.maxContextTokens * 0.9 {
    // context 快满了,截断早期历史(保留系统指令和最近几轮)
    inv.Session.Messages = f.truncateHistory(inv.Session.Messages)
}

3.10 完整的数据流图

用户消息
    ↓
┌─────────────────────────────────────────────────────┐
│  LLM Flow                                           │
│                                                     │
│  ┌──── requestProcessors 链 ────┐                   │
│  │ SystemInstruction            │                   │
│  │ SessionHistory               │                   │
│  │ ToolsInjection              │                   │
│  │ Planner                     │                   │
│  │ ContextWindow               │                   │
│  └──────────────────────────────┘                   │
│              ↓                                      │
│  ┌──── LLM 调用(流式)────┐                        │
│  │ 发送请求                │                        │
│  │ 接收流式 token          │ → Event: streaming     │
│  │ 组装完整响应            │                        │
│  └─────────────────────────┘                        │
│              ↓                                      │
│  ┌──── 响应分析 ────┐                               │
│  │ 有 tool_call?    │                               │
│  │   ├─ YES → 执行工具 → Event: tool_call/result   │
│  │   │         结果追加 session                     │
│  │   │         → 继续循环                          │
│  │   └─ NO  → 最终答案 → Event: final_response    │
│  │              → 退出循环                          │
│  └──────────────────┘                               │
│                                                     │
└─────────────────────────────────────────────────────┘

思考题

Q1(核心理解):在 ReAct 循环中,LLM 怎么”知道”它应该调用工具还是直接给答案?(提示:想想 LLM 请求中的 tools 参数和 system prompt 是如何引导 LLM 行为的)

Q2(设计决策):tRPC-Agent 把工具执行结果追加到 session 消息历史中,LangGraph 把结果写入 Channel。这两种方式在”信息衰减”方面有什么区别?(提示:session 越长,早期信息被 LLM 关注的概率越低;Channel 是精确的 key-value 存储)

Q3(生产问题):假设你的 Agent 有 10 个工具,但 LLM 每次都只调用其中 2-3 个。把全部 10 个工具的描述都放在 LLM 请求里好吗?如果有 100 个工具呢?你能想到什么优化策略?

Q4(断点恢复)maybeResumePendingToolCalls 机制假设工具是幂等的(同一个输入执行两次得到同样结果)。如果工具不是幂等的(比如”发邮件”),这个断点恢复机制会有什么问题?怎么解决?

Q5(流式 vs 批式):tRPC-Agent 的 Event Channel 设计让用户能实时看到 Agent 的思考过程。但如果 Agent 正在做的事是”搜索 10 个网页、汇总后给出答案”,中间过程对用户有价值吗?什么场景下流式输出是必要的,什么场景下”安静等结果”更好?


上一章:第二章 tRPC-Agent-Go 全架构精读

下一章:第四章 Team 多 Agent 协作

返回目录:Agent 框架深度导读系列