第三章: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 决定给出最终答案
日常类比:你在修一台不出水的洗衣机。
- Think:水龙头开了吗?可能是进水阀堵了。先检查进水管。
- Act:拔下进水管,看看有没有水流出来。
- Observe:有水,说明不是水源问题。是进水阀本身坏了。
- Think:需要更换进水阀。先查一下型号。
- Act:拍照阀门上的标签,搜索型号。
- Observe:型号是 XYZ-123,淘宝有卖。
- 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 请求。比如:
- 注入 RAG 检索结果
- 根据用户身份过滤可用工具
- 动态调整 temperature
阶段二: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 个网页、汇总后给出答案”,中间过程对用户有价值吗?什么场景下流式输出是必要的,什么场景下”安静等结果”更好?
返回目录:Agent 框架深度导读系列