第八章:跨框架对比——状态管理哲学
本章目标:理解三种主流状态管理范式的设计哲学、权衡与适用场景,建立”状态问题是 Agent 框架的核心分水岭”的认知。
8.1 什么是”状态”?
日常类比
想象你在做一道复杂的数学应用题。你一边算一边在草稿纸上记东西:中间结果、已知条件、还没用上的信息、之前算错划掉重来的痕迹。这张草稿纸上的一切,就是你解题过程中的”状态”。
如果有人突然把你的草稿纸拿走,你就得从头来过。如果草稿纸太小写满了,你就不得不擦掉一些”可能还有用”的东西。如果两个人要共用一张草稿纸同时往上写,就可能互相覆盖。
Agent 框架的状态管理,本质上就是在解决这三个问题:
- 怎么记(数据结构和存储形式)
- 记多少(容量限制和清理策略)
- 谁来记(单点 vs 多点写入的冲突解决)
技术定义
在 Agent 系统中,状态(State) 是执行过程中需要持久化或在步骤间传递的所有信息,包括但不限于:
- 对话历史(用户输入 + LLM 输出 + 工具调用结果)
- 中间计算结果(搜索结果、解析后的数据)
- 控制流信息(当前在哪一步、下一步该去哪)
- 累积知识(已确认的事实、已排除的方案)
- 元数据(token 消耗、时间戳、重试次数)
不同框架对”状态应该长什么样”这个问题给出了截然不同的答案,这些不同的答案反映了截然不同的设计哲学。
为什么状态管理是核心分水岭
状态管理不只是一个”实现细节”,它决定了框架能做什么和不能做什么:
- 状态结构决定了并发能力:能不能并行执行多个节点?
- 状态粒度决定了恢复能力:crash 后能不能从中间步骤恢复?
- 状态传递决定了协作模式:多个 Agent 怎么共享信息?
- 状态大小决定了成本:token 消耗随执行轮次怎么增长?
可以说,选择了哪种状态管理范式,就选择了哪种框架的灵魂。
8.2 三种状态管理范式总览
对比表
| 维度 | Session追加模式(tRPC-Agent) | Channel类型化模式(LangGraph) | Task输出传递模式(CrewAI) |
|---|---|---|---|
| 核心隐喻 | 聊天记录 | 电子表格的多列 | 流水线传送带 |
| 数据结构 | []Message 有序列表 |
map[string]Channel 键值对 |
TaskOutput 链 |
| 写入方式 | append-only 追加 | reducer 合并 | 上游输出→下游输入 |
| 读取方式 | 全量/窗口读取 | 按 key 精确读取 | 读上一个 Task 的 output |
| 并发模型 | 串行(单 Agent 循环) | 并行(多节点 + reducer) | 串行(Sequential)/ 委派(Hierarchical) |
| 持久化 | 9种后端存session历史 | Checkpoint完整快照 | 无内建(Flow @persist 后补) |
| 适用场景 | 对话式、单 Agent 为主 | 复杂工作流、多状态维度 | 流水线式任务分解 |
设计哲学的根源
这三种范式不是随意选择的,它们源自不同的”世界观”:
- tRPC-Agent(Session追加):Agent 就是对话。世界是一连串消息的交换。灵感来自 chat completion API。
- LangGraph(Channel类型化):Agent 就是状态机。世界是一组状态通道在图结构上的变换。灵感来自数据流编程和 React/Redux。
- CrewAI(Task输出传递):Agent 就是工人。世界是一条流水线上的任务分工。灵感来自人类团队协作和项目管理。
8.3 范式一:Session 追加模式(tRPC-Agent)
核心思想
把 Agent 的执行过程看作一次”对话”。状态 = 消息列表。每一步的输入和输出都被 append 到这个列表末尾,LLM 每次决策时读取完整(或窗口化)的历史。
日常类比
就像微信群聊记录。每个人说的话(用户消息)、AI 的回复、工具返回的结果,都按时间顺序排在一起。要理解当前情况,你往上翻聊天记录就好。记录越长,翻起来越费劲,但信息最完整。
数据结构
// tRPC-Agent 的核心状态结构
type Session struct {
ID string
Messages []Message // 有序追加,这就是全部状态
Metadata map[string]interface{}
}
type Message struct {
Role string // "user" | "assistant" | "tool"
Content string
ToolCalls []ToolCall // LLM 决定调用哪些工具
ToolResults []ToolResult // 工具返回的结果
Timestamp time.Time
}
执行流程
用户输入 → append到Messages → LLM读取全部Messages → LLM输出
→ append到Messages → 如果有tool_call → 执行工具 → 结果append
→ LLM再读取全部Messages → ... 循环直到结束
优势深度分析
1. 概念简单——零额外心智模型
状态就是消息列表,不需要定义 schema、声明 reducer、理解通道类型。这意味着:
- 新手上手快:写过 chat completion API 的人立刻理解
- 出错概率低:没有类型声明可以写错,没有 reducer 可以写 bug
- 文档轻量:不需要解释复杂的状态管理概念
2. 调试直观——所见即所得
打开 session 历史就能看到完整的执行过程:
[
{"role": "user", "content": "帮我查今天北京天气"},
{"role": "assistant", "tool_calls": [{"name": "weather_api", "args": {"city": "北京"}}]},
{"role": "tool", "content": "北京今天晴,25°C"},
{"role": "assistant", "content": "北京今天是晴天,气温25度。"}
]
任何人看到这段历史都能理解发生了什么,不需要理解”Channel 合并逻辑”或”reducer 执行顺序”。
3. 天然兼容 LLM 接口
所有主流 LLM API(OpenAI、Anthropic、Google)的输入格式就是 []Message。session 模式直接对接,零转换成本。而 Channel 模式需要把结构化状态”投影”成 messages 才能喂给 LLM。
4. 恢复极其简单
保存 session 就是保存完整历史。恢复就是加载历史,让 LLM 从上次的最后一条消息之后继续。不需要理解”哪些 Channel 被恢复了”“当前在图的哪个节点”。
劣势深度分析
1. 线性增长——Token 成本不可控
每个工具调用的完整输入输出都在历史里。假设一次搜索返回 2000 token,10轮搜索就是 20000 token 的纯历史。这些 token 每次 LLM 调用都要重新读取和付费。
轮次1: 用户问题(100) + LLM回复(200) + 工具结果(2000) = 2300 token
轮次5: 累积 2300 × 5 = 11500 token(每次LLM调用都要读这么多)
轮次10: 累积 23000 token(接近 GPT-4 的上下文限制)
2. 信息冗余——过时信息占位
第 3 轮的搜索结果可能已经被第 7 轮更好的结果完全取代了,但它仍然占据 context window 空间,浪费 token 并可能干扰 LLM 判断。
3. 不适合复杂工作流
当你需要维护多个独立维度的状态时:
需要追踪的状态维度:
- 用户偏好(覆盖型:新偏好替换旧偏好)
- 搜索结果集合(追加型:不断累积)
- 当前进度(覆盖型:到哪一步了)
- 待处理队列(先进先出)
- 确认的事实(追加+去重)
把这些全塞进 []Message 是不自然的——LLM 要从大量消息中”提取”这些结构化信息,效率低且容易遗漏。
4. 多 Agent 共享困难
多个 Agent 共享同一个 session 历史意味着:
- 搜索 Agent 的详细搜索结果,写作 Agent 也得读(但不需要)
- 每个 Agent 的内部推理过程污染其他 Agent 的输入
- 信噪比随 Agent 数量下降
tRPC-Agent 的优化机制:ContextWindowProcessor
tRPC-Agent 没有停留在”裸 session”层面,而是提供了精巧的截断/压缩策略:
// ContextWindowProcessor 接口
type ContextWindowProcessor interface {
Process(messages []Message) []Message
}
// 策略1:滑动窗口——只保留最近 N 条
type SlidingWindowProcessor struct {
MaxMessages int // 比如保留最近50条
// 优点:实现简单、O(1)决策
// 缺点:可能丢失关键的早期上下文(如用户最初的需求描述)
}
// 策略2:Token 预算——超过阈值从最旧的开始移除
type TokenBudgetProcessor struct {
MaxTokens int // 比如预算 8000 token
Counter TokenCounter
// 优点:精确控制成本
// 缺点:可能截断到消息中间(需要按整条消息移除)
}
// 策略3:摘要压缩——把旧消息压缩成一条摘要
type SummarizationProcessor struct {
Threshold int // 超过多少条消息触发压缩
Summarizer func([]Message) Message
// 优点:保留信息语义,减少token
// 缺点:压缩本身消耗token;摘要可能丢信息
}
// 策略4:混合策略——保留首尾 + 中间压缩
type HybridProcessor struct {
KeepFirst int // 保留最初几条(通常含用户需求)
KeepLast int // 保留最近几条(当前上下文)
Summarizer func([]Message) Message // 中间部分压缩
}
这些 Processor 组合使用,在保持 append-only 简洁性的同时解决了 context 爆炸问题。关键洞察是:存储层保持完整历史(可恢复),但发给 LLM 的视图是处理过的(可控成本)。
8.4 范式二:Channel 类型化模式(LangGraph)
核心思想
把状态拆解为多个独立的命名通道(Channel),每个通道有自己的类型和合并规则(reducer)。节点从通道中读取需要的状态切片,执行后向通道写入结果。
日常类比
想象一个手术室里的白板墙。白板被分成了很多区域(通道):一块写”患者基本信息”、一块写”当前生命体征”、一块写”已用药物列表”、一块写”待做检查项”。
每个医护人员不需要读整个白板——麻醉师只看生命体征那块,药剂师只看用药列表那块。往白板上写东西也有规则:生命体征区只保留最新值(覆盖),用药列表区是追加(不能擦掉之前的记录)。
这就是 Channel 模式的精髓:分区存储 + 按规则合并。
数据结构
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph
import operator
# 定义状态的"白板":多个类型化通道
class AgentState(TypedDict):
# 消息历史:追加模式(reducer = operator.add)
messages: Annotated[list, operator.add]
# 当前任务:覆盖模式(默认 reducer = 最后一次写入覆盖)
current_task: str
# 搜索结果:追加模式
search_results: Annotated[list, operator.add]
# 进度计数器:取最大值(用于多节点并行时合并)
progress: Annotated[int, max]
# 最终答案:覆盖模式
final_answer: str
Reducer 机制详解
Reducer 是 Channel 模式最核心的创新。它回答了一个关键问题:当多个来源向同一个通道写入时,如何合并?
import operator
# ===== 内建 Reducer 策略 =====
# 1. 追加(消息历史、结果收集)
messages: Annotated[list, operator.add]
# 写入 ["新消息"] → 旧值 ["旧消息1", "旧消息2"] + ["新消息"]
# 最终值 = ["旧消息1", "旧消息2", "新消息"]
# 2. 覆盖(当前状态、最新值)
current_step: str # 不加 Annotated 默认覆盖
# 写入 "step_3" → 无论旧值是什么,直接变成 "step_3"
# 3. 取最大/最小值(进度、评分)
progress: Annotated[int, max]
# 节点A写入 5,节点B写入 3 → 合并后 = 5
# ===== 自定义 Reducer =====
# 4. 去重追加(搜索结果去重)
def dedup_add(existing: list, new: list) -> list:
"""追加新元素,但跳过已存在的"""
seen = set(item["id"] for item in existing)
return existing + [item for item in new if item["id"] not in seen]
results: Annotated[list, dedup_add]
# 5. 有界追加(只保留最近N条)
def bounded_add(existing: list, new: list, max_size: int = 100) -> list:
"""追加但保持总量不超过 max_size"""
combined = existing + new
return combined[-max_size:] # 保留最近的
# 6. 投票合并(多节点投票决策)
def majority_vote(existing: dict, new: dict) -> dict:
"""合并多个节点的投票,返回当前票数"""
merged = {**existing}
for key, votes in new.items():
merged[key] = merged.get(key, 0) + votes
return merged
votes: Annotated[dict, majority_vote]
节点如何与 Channel 交互
# 节点只读写自己关心的通道
def search_node(state: AgentState) -> dict:
"""搜索节点:读 current_task,写 search_results"""
query = state["current_task"] # 只读自己需要的
results = do_search(query)
return {"search_results": results} # 只写自己负责的
# 框架自动用对应 reducer 合并
def analyze_node(state: AgentState) -> dict:
"""分析节点:读 search_results,写 final_answer"""
data = state["search_results"] # 读搜索结果
answer = analyze(data)
return {"final_answer": answer} # 覆盖写最终答案
# 图定义
graph = StateGraph(AgentState)
graph.add_node("search", search_node)
graph.add_node("analyze", analyze_node)
graph.add_edge("search", "analyze")
BinaryOperatorAggregate 内部实现
# LangGraph 内部用 BinaryOperatorAggregate 实现 Channel
class BinaryOperatorAggregate:
"""Channel 的内部实现:值 + 合并函数"""
def __init__(self, value=None, operator=None):
self.value = value
self.operator = operator # (旧值, 新值) -> 合并值
def update(self, new_value):
if self.value is None:
self.value = new_value
else:
self.value = self.operator(self.value, new_value)
def get(self):
return self.value
每个 Channel 本质上是”一个值 + 一个二元合并函数”,这个简单的抽象足以覆盖绝大多数并发写入场景。
优势深度分析
1. 精确读取——高信噪比
每个节点只读自己需要的通道。搜索节点不需要知道分析节点的中间推理过程,分析节点也不需要知道搜索查询的详细日志。这在大规模系统中至关重要——避免了 “信息洪水” 问题。
2. 并行友好——原生支持多节点同时执行
# 三个搜索节点并行,结果通过 reducer 自动合并
graph.add_edge(START, "search_web")
graph.add_edge(START, "search_docs")
graph.add_edge(START, "search_db")
# 三个节点各自返回 {"findings": [...]}
# reducer (operator.add) 自动合并为完整列表
不需要手动加锁、排队、或解决竞态条件。Reducer 在语义层面就定义好了”怎么合”。
3. 内存高效——覆盖型 Channel 不增长
覆盖型通道(如 current_step)永远只占一个值的空间,不会像消息列表那样随时间线性增长。这意味着对于”进度跟踪”“当前状态”这类信息,Channel 模式的内存开销是 O(1) 的。
4. 类型安全——IDE 友好
TypedDict 提供编译时类型检查:
# IDE 能自动补全,写错 key 名立刻报错
def bad_node(state: AgentState) -> dict:
x = state["nonexistent_key"] # ← IDE 警告:key 不存在
return {"typo_key": 123} # ← IDE 警告:不在 State 定义中
5. 快照友好——天然适合 Checkpoint
状态是结构化 dict,序列化/反序列化只是 JSON dump/load,成本极低。而消息列表可能包含大量文本内容,序列化成本高。
劣势深度分析
1. 学习曲线陡峭
新手常见困惑:
- “为什么不能直接
state['x'] = 123?” → 因为需要走 reducer - “Annotated 的第二个参数是什么?” → 需要理解 Python typing 高级用法
- “我的 reducer 为什么没生效?” → reducer 只在节点返回值合并时触发
2. 前期设计成本高
需要提前规划:哪些信息放哪个 Channel?用什么 Reducer?如何处理 Channel 间的依赖关系?设计不好后期重构成本大(因为所有节点都依赖 State 结构)。
3. 调试间接
出错时的排查链更长:
- Session 模式:看消息列表第 N 条就知道第 N 步发生了什么
- Channel 模式:需要理解 reducer 逻辑 + 重放节点执行顺序才能定位”这个 Channel 为什么变成了这个值”
4. 过度工程风险
对于简单的”问一答一”对话 Agent,定义 TypedDict + Reducer + Graph 结构就像用大炮打蚊子:
# 简单对话 Agent 用 Channel 模式——显得很重
class SimpleState(TypedDict):
messages: Annotated[list, operator.add]
graph = StateGraph(SimpleState)
graph.add_node("chat", chat_node)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
app = graph.compile()
# 对比 tRPC-Agent——三行搞定
agent := NewAgent(llm, tools)
agent.Run(userMessage)
8.5 范式三:Task 输出传递模式(CrewAI)
核心思想
状态不是一个全局的”池子”,而是在 Task 之间像接力棒一样传递的。上一个 Task 的输出自动成为下一个 Task 的输入上下文。
日常类比
想象一条汽车生产流水线。焊接工位完成车身焊接,把焊好的车身(输出)传给下一个喷漆工位。喷漆工位不需要知道焊接用了什么电流、花了多久——它只需要”车身焊好了”这个结果。喷完漆,把喷好的车身传给总装工位。
每个工位只接收上一步的”成品”,不需要了解整条流水线的完整历史。
数据结构
from crewai import Agent, Task, Crew
# Task 的输出自动传递给后续 Task
research_task = Task(
description="Research the topic: {topic}",
expected_output="A comprehensive research report with key findings",
agent=researcher
)
writing_task = Task(
description="Write an article based on the research findings",
# context 参数显式声明依赖哪些前置 Task 的输出
context=[research_task],
expected_output="A well-written article of 1000 words",
agent=writer
)
review_task = Task(
description="Review the article for accuracy and quality",
context=[research_task, writing_task], # 可以引用多个前置
expected_output="Review feedback with specific suggestions",
agent=editor
)
TaskOutput 的内部结构
# CrewAI 内部的输出传递机制
class TaskOutput:
raw: str # 原始文本输出(总是有)
pydantic: BaseModel # 结构化输出(如果定义了 output_pydantic)
json_dict: dict # JSON 输出(如果定义了 output_json)
agent: str # 哪个 Agent 产出的
# 下游 Task 的处理逻辑(简化):
# 1. 获取 context 中所有前置 Task 的 TaskOutput
# 2. 把每个 TaskOutput.raw 拼接成上下文字符串
# 3. 注入到下游 Task 的 prompt 中
# 4. Agent 基于"任务描述 + 前置输出上下文"执行
执行流程
Task1 执行 → 产出 TaskOutput1
→ Task2 的 prompt 中注入 "前置任务输出: TaskOutput1.raw"
→ Task2 执行 → 产出 TaskOutput2
→ Task3 的 prompt 中注入 "前置任务输出: TaskOutput1.raw + TaskOutput2.raw"
→ Task3 执行 → 最终结果
优势深度分析
1. 认知负担最低——流水线隐喻直觉
不需要理解 Channel、Reducer、Session 管理、Graph 编译。”上一步做完了,把结果给下一步”——这就是全部。任何非技术背景的人都能理解。
2. 天然隔离——高信噪比
每个 Task 只看到自己声明的 context 中的前置输出。搜索 Task 的详细日志不会传给写作 Task(除非显式声明)。这种”需要知道才给”的原则保证了高信噪比。
3. 天然限制累积量——不会 Context 爆炸
不是所有历史都堆积起来。如果流水线有10步,最后一步只需要看第9步的输出(加上显式声明的其他步),而不是把前9步的所有工具调用细节都带上。
4. 可预测性强——输入来源透明
每个 Task 的输入来源是确定的(context 参数显式声明),调试时可以精确追溯:”Task3 的输出质量不好,是因为 Task1 的研究结果不够深入?还是 Task2 的写作没抓住重点?”
劣势深度分析
1. 灵活性不足——传递路径静态
如果 Task5 需要 Task1 的原始数据(跨过 Task2/3/4),必须在 context 中显式声明。对于复杂的依赖关系,context 列表会变得很长且难以维护。
2. 无内建持久化——crash = 从头来
# 如果第 5 个 Task 执行到一半崩了...
crew = Crew(tasks=[t1, t2, t3, t4, t5, t6])
result = crew.kickoff() # t5 crash,前4个的结果全丢了
# 没有内建机制从 t5 恢复
# 必须自己在外部保存中间结果
3. 状态窄——只有最终输出文本
Task 内部的推理过程、工具调用详情、中间状态变化——这些全部丢失了,只传递最终的 raw 文本。如果下游需要”上游搜索时用了哪些关键词”这种过程信息,这个模型就不够用。
4. 不适合迭代式场景
# 想实现"写→审→改→再审→再改"的循环
# 纯 Task 链做不到——它是线性的,不能回头
# 只能用 CrewAI Flow 弥补(但那就不是纯 Task 传递模式了)
CrewAI Flow 的演进
CrewAI 后来引入 Flow 来补足状态管理的灵活性:
from crewai.flow.flow import Flow, start, listen, router
class ContentFlow(Flow):
# Flow 有自己的 state(一个扁平 dict)
model_config = ConfigDict(arbitrary_types_allowed=True)
research_result: str = ""
draft: str = ""
revision_count: int = 0
@start()
def research(self):
result = research_crew.kickoff()
self.research_result = result.raw
return result
@listen(research)
def write_draft(self):
draft = writing_crew.kickoff(inputs={"research": self.research_result})
self.draft = draft.raw
return draft
@router(write_draft)
def quality_check(self):
score = evaluate_quality(self.draft)
if score > 0.8 or self.revision_count >= 3:
return "publish"
self.revision_count += 1
return "revise"
@listen("revise")
def revise_draft(self):
# 可以回到 write_draft 形成循环
revised = revision_crew.kickoff(inputs={"draft": self.draft})
self.draft = revised.raw
return revised
@listen("publish")
def publish(self):
return self.draft
Flow 在 Task 输出传递模式上叠加了”事件驱动 + 条件路由 + 实例变量”,获得了有限的动态能力和状态累积能力。但它的 state 仍然比 LangGraph Channel 简单——没有 reducer,没有类型约束,就是普通的 class 属性。
8.6 并发写入问题
问题定义
当多个计算单元(节点/Agent)同时产出结果并需要写入共享状态时,如何保证不丢失、不冲突、不混乱?
日常类比:多个人同时编辑同一个 Google Doc——如果没有冲突解决机制,就会互相覆盖对方的修改。
LangGraph: Reducer 优雅解决
LangGraph 的并发写入在设计层面就解决了——通过声明式 Reducer:
from langgraph.graph import StateGraph
from typing import TypedDict, Annotated
import operator
class ResearchState(TypedDict):
# 多个搜索节点并行,结果全部追加
findings: Annotated[list, operator.add]
# 多个评分节点并行,取最高分
confidence: Annotated[float, max]
# 多个节点投票
votes: Annotated[dict, merge_votes]
graph = StateGraph(ResearchState)
graph.add_node("search_web", search_web_node)
graph.add_node("search_docs", search_docs_node)
graph.add_node("search_db", search_db_node)
# 三个节点从同一个起点出发,并行执行
graph.add_edge(START, "search_web")
graph.add_edge(START, "search_docs")
graph.add_edge(START, "search_db")
# 并行汇合点
graph.add_edge("search_web", "synthesize")
graph.add_edge("search_docs", "synthesize")
graph.add_edge("search_db", "synthesize")
# 执行时:
# search_web 返回 {"findings": [r1, r2], "confidence": 0.8}
# search_docs 返回 {"findings": [r3], "confidence": 0.6}
# search_db 返回 {"findings": [r4, r5], "confidence": 0.9}
#
# 框架自动合并:
# findings = [r1, r2] + [r3] + [r4, r5] = [r1, r2, r3, r4, r5]
# confidence = max(0.8, 0.6, 0.9) = 0.9
关键优势:Reducer 是声明式的——你在定义 State 时就说清楚了”多个值怎么合”,运行时框架自动执行。不需要:
- 手动加锁(mutex/semaphore)
- 消息队列排队
- 乐观锁 + 重试
- CAS 操作
tRPC-Agent: 架构消除并发问题
tRPC-Agent 的设计选择是通过架构约束消除并发问题,而不是提供解决方案:
// tRPC-Agent 的 ReAct 循环——严格串行
for {
response := llm.Chat(session.Messages) // 1. LLM 决策
session.Messages = append(session.Messages, response)
if len(response.ToolCalls) == 0 {
break // 没有工具调用,结束
}
for _, toolCall := range response.ToolCalls {
result := executeTool(toolCall) // 2. 串行执行工具
session.Messages = append(session.Messages, result)
}
// 3. 回到步骤1,用更新后的历史让LLM再决策
}
即使在多 Agent 协作时(Coordinator/Swarm),每个 Agent 有独立的 session。Agent 之间通过消息传递通信(一个 Agent 的输出作为另一个的输入),而不是写入同一个共享状态:
// Coordinator 模式:coordinator 串行调用子 Agent
type Coordinator struct {
SubAgents map[string]*Agent
}
func (c *Coordinator) Run(task string) string {
// Coordinator 决定让谁做什么——串行分配
assignment := c.decide(task)
result := c.SubAgents[assignment.AgentName].Run(assignment.SubTask)
// 结果回到 Coordinator 的 session,不存在共享写入
return result
}
设计哲学差异:LangGraph 说”并发是有价值的,我提供 reducer 让你安全地并发”;tRPC-Agent 说”对于大多数 Agent 场景,串行足够好,不引入并发就不需要解决并发问题”。
CrewAI: 流程约束天然避免
# Sequential:严格顺序,不可能并发
crew = Crew(
agents=[researcher, writer, editor],
tasks=[research_task, write_task, edit_task],
process=Process.sequential
)
# Hierarchical:Manager 串行分配
# Manager 一次只把一个子任务交给一个 Agent
# 结果汇总到 Manager,Worker 之间不通信
crew = Crew(
agents=[researcher, writer, editor],
tasks=[complex_task],
process=Process.hierarchical,
manager_llm=ChatOpenAI(model="gpt-4")
)
CrewAI 的选择和 tRPC-Agent 类似——通过流程约束消除问题。但代价是无法享受并行带来的加速。
并发写入对比总结
| 框架 | 是否存在并发写入 | 解决方式 | 优点 | 代价 |
|---|---|---|---|---|
| LangGraph | 是(支持并行节点) | 声明式 Reducer | 并行加速、高吞吐 | 需理解 reducer 语义 |
| tRPC-Agent | 否(架构消除) | 串行循环 + 独立 session | 零并发心智负担 | 无法并行加速 |
| CrewAI | 否(流程约束) | Sequential/Hierarchical | 零并发心智负担 | 无法并行加速 |
8.7 持久化与恢复
为什么需要持久化?
日常类比:你在写一篇很长的论文。如果你的电脑只有”不保存”的选项——突然断电,你从第一个字开始重新写。持久化就是”自动保存”功能,而且支持”撤销到之前任何一个版本”。
具体场景:
- 长时间任务:研究类 Agent 可能跑数小时,中途网络抖动不能从头再来
- 人机协同:用户让 Agent 跑着,自己去开会,回来要接着看
- 调试回溯:Agent 第 7 步做了错误决策,想看第 6 步时状态是什么样,从那里分叉
- A/B 测试:同一个起点 fork 出两条路径,比较哪条效果好
LangGraph Checkpoint:最强的持久化
LangGraph 的 Checkpoint 提供了完整的”时间旅行”能力:
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph
# 1. 配置 checkpoint 后端
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
# 也支持 PostgreSQL、Redis、自定义后端
# 2. 编译图时传入 checkpointer
app = graph.compile(checkpointer=checkpointer)
# 3. 执行——每个节点执行后自动保存快照
config = {"configurable": {"thread_id": "research-task-001"}}
result = app.invoke({"messages": [user_msg]}, config=config)
# 4. 恢复:O(1) 时间加载最新快照
state = app.get_state(config)
print(state.values) # 所有 Channel 的当前值
print(state.next) # 下一步应该执行哪个节点
# 5. 历史:获取所有 checkpoint
history = list(app.get_state_history(config))
# history[0] 是最新的,history[-1] 是最初的
# 6. Fork:从历史某点开始新分支
old_checkpoint = history[5]
fork_config = {"configurable": {
"thread_id": "research-task-001-fork-a",
"checkpoint_id": old_checkpoint.config["configurable"]["checkpoint_id"]
}}
# 从第5个checkpoint分叉,尝试不同策略
app.invoke({"messages": [alternative_instruction]}, fork_config)
# 7. Rollback:回退到某个历史状态
app.update_state(
config,
old_checkpoint.values, # 用旧值覆盖当前值
as_node=old_checkpoint.next[0] # 指定从哪个节点恢复
)
核心优势:
- O(1) 恢复:直接加载结构化快照,不需要重放步骤
- Fork/Rollback:可以从任意历史节点分叉或回退,支持”时间旅行调试”
- 完整性:快照包含所有 Channel 值,恢复后状态 100% 一致
- 粒度细:每个节点执行后都有快照,不是”每轮对话”粒度
tRPC-Agent Session 持久化
tRPC-Agent 围绕 Session 做持久化,提供 9 种存储后端:
// Session 存储接口
type SessionStorage interface {
Save(session *Session) error
Load(sessionID string) (*Session, error)
Delete(sessionID string) error
List() ([]string, error)
}
// 9 种后端实现
var storageBackends = map[string]SessionStorage{
"memory": NewMemoryStorage(), // 开发测试
"file": NewFileStorage(path), // 单机部署
"redis": NewRedisStorage(conn), // 高性能分布式
"mysql": NewMySQLStorage(dsn), // 结构化查询
"postgres": NewPostgresStorage(dsn),// 结构化查询
"mongodb": NewMongoStorage(uri), // 文档型
"dynamodb": NewDynamoStorage(cfg), // AWS 原生
"sqlite": NewSQLiteStorage(path), // 嵌入式
"firestore": NewFirestoreStorage(p), // GCP 原生
}
恢复流程:
// 保存
storage.Save(session) // session 包含完整 Messages 列表
// 恢复
session, _ := storage.Load("session-123")
// LLM 重新读取 session.Messages 继续对话
response := llm.Chat(session.Messages)
注意差异:tRPC-Agent 的恢复依赖 LLM 重新理解历史。如果历史超过 context window,早期信息可能”理解不到”。而 LangGraph 的恢复是精确的——状态是结构化数据({"current_task": "X", "findings": [...]}),不依赖 LLM 理解能力。
CrewAI:后补的有限持久化
CrewAI 最初没有持久化。后来通过 Flow 的 @persist 装饰器补充了有限能力:
from crewai.flow.flow import Flow, start, listen
class ResearchFlow(Flow):
# Flow state(简单 dict)
research_data: str = ""
@start()
def research(self):
result = research_crew.kickoff()
self.research_data = result.raw
# @persist 会在这里保存 self 的所有属性
return result
局限性:
- 只在标记了
@persist的方法执行后保存 - 没有自动每步保存
- 没有 fork/rollback 能力
- 没有历史列表——只有”最新保存点”
生产中的做法:团队通常自己实现 checkpoint:
import json
class RobustFlow(Flow):
checkpoint_path: str = "checkpoints/"
@start()
def step1(self):
result = crew1.kickoff()
# 手动保存
self._save_checkpoint("step1", result.raw)
return result
def _save_checkpoint(self, step_name, data):
with open(f"{self.checkpoint_path}/{step_name}.json", "w") as f:
json.dump({"step": step_name, "data": data}, f)
def _try_restore(self, step_name):
path = f"{self.checkpoint_path}/{step_name}.json"
if os.path.exists(path):
with open(path) as f:
return json.load(f)["data"]
return None
持久化能力对比总结
| 能力 | LangGraph | tRPC-Agent | CrewAI |
|---|---|---|---|
| 自动每步保存 | 是(每节点后) | 否(需主动调Save) | 否(需 @persist) |
| 恢复精度 | 100%(结构化快照) | 取决于LLM理解 | 有限(只恢复 state 属性) |
| Fork 分支 | 原生支持 | 不支持 | 不支持 |
| Rollback 回退 | 原生支持 | 手动实现(截断历史) | 不支持 |
| 历史浏览 | get_state_history | 不支持 | 不支持 |
| 存储后端 | SQLite/PG/Redis/自定义 | 9种后端 | 无内建(自理) |
| 适合场景 | 长时间工作流、需要调试 | 对话式、需要分布式 | 简单流水线 |
8.8 Context Window 管理
问题定义
LLM 有固定的 context window 大小(GPT-4: 128K token, Claude: 200K token)。当 Agent 执行过程中积累的状态超过这个限制时怎么办?
日常类比:你的工作台面积有限。当桌上堆满了资料时,你必须做出选择——要么把一些资料收到抽屉里(压缩),要么扔掉一些(截断),要么只保留摘要贴在墙上(总结)。
tRPC-Agent: ContextWindowProcessor 截断策略
tRPC-Agent 把这个问题显式化了——你可以组合多种 Processor 来管理发给 LLM 的内容:
// 实际使用示例:组合多种策略
agent := NewAgent(llm, tools,
WithContextWindowProcessors(
// 策略1:永远保留第一条(用户原始需求)
KeepFirstProcessor{Count: 1},
// 策略2:保留最近20条消息
SlidingWindowProcessor{MaxMessages: 20},
// 策略3:总 token 不超过 8000
TokenBudgetProcessor{MaxTokens: 8000},
// 策略4:中间消息压缩成摘要
SummarizationProcessor{
Threshold: 30, // 超过30条触发
Summarizer: func(msgs []Message) Message {
// 调用 LLM 生成摘要
summary := llm.Summarize(msgs)
return Message{Role: "system", Content: "历史摘要: " + summary}
},
},
),
)
关键设计:存储层保持完整历史(可恢复、可审计),发给 LLM 的是处理过的视图。这两层分离是 tRPC-Agent 在 session 模式下解决 context 爆炸的核心手段。
[完整Session历史] → [Processor管道] → [发给LLM的消息]
↑ 持久化层 ↑ 呈现层
保存一切 只给LLM看它需要的
LangGraph: Channel 天然精简
Channel 模式在结构层面就缓解了 context 爆炸:
class AgentState(TypedDict):
# 覆盖型 Channel:永远只占一个值的空间
current_task: str # O(1) 空间
current_step: str # O(1) 空间
final_answer: str # O(1) 空间
# 有界追加:自定义 reducer 控制上限
search_results: Annotated[list, bounded_add_10] # 最多10条
# 唯一可能增长的:messages
messages: Annotated[list, operator.add] # 需要额外管理
为什么 Channel 天然比 Session 精简?
在 Session 模式中,一切都是 message——工具调用参数、工具返回结果、LLM 思考过程,全部序列化为消息文本。而 Channel 模式中,只有 messages Channel 存对话历史,其他信息以结构化键值对形式存储:
# Session 模式下,状态积累方式:
# [user_msg, llm_response, tool_call_1, tool_result_1, llm_response_2,
# tool_call_2, tool_result_2, ..., llm_final_answer]
# 每一条都是完整文本,token 线性增长
# Channel 模式下:
# messages: [user_msg, final_answer] ← 只保留对话级消息
# search_results: [top_3_results] ← 结构化数据,有界
# current_step: "synthesize" ← 单值覆盖
# LLM 只需要读 messages + 当前节点关心的 Channel
LangGraph 中 LLM 的输入通常是由节点函数构造的——节点可以选择性地从 state 中取出需要的信息组装 prompt,而不是把整个 state dump 给 LLM。
CrewAI: Task 间传递天然限制
CrewAI 的”流水线”模式从结构上就限制了累积:
# 10 个 Task 的流水线
# Task10 只看到 context 声明的前置 Task 输出
# 不是 Task1~Task9 的所有历史都堆起来
review_task = Task(
description="Final review",
context=[research_task, final_draft_task], # 只看这两个的输出
# 中间的搜索、整理、草稿修改过程——Task10 完全不知道
expected_output="Final review notes"
)
Token 使用量对比:
假设每个 Task 产出约 1000 token 的输出
Session 模式(10轮):
LLM第10次调用的输入 ≈ 10 × 1000 = 10000+ token
Channel 模式(10个节点):
最后节点的输入 = 它关心的 Channel 内容 ≈ 2000-3000 token
Task 传递(10个Task,context只看上一个):
最后 Task 的输入 = 上一个 Task 的输出 ≈ 1000 token
Context Window 管理对比
| 维度 | tRPC-Agent | LangGraph | CrewAI |
|---|---|---|---|
| 增长模式 | 线性(每轮累积) | 亚线性(Channel 精简) | 常数(只看前置输出) |
| 管理手段 | ContextWindowProcessor | 节点函数选择性读取 | context 显式声明 |
| 灵活度 | 高(多种策略可组合) | 中(需在节点逻辑中处理) | 低(只能选择看哪些 Task 输出) |
| 信息丢失风险 | 有(截断/压缩可能丢重要信息) | 低(结构化数据不易丢) | 中(中间过程信息天然丢失) |
| 成本控制 | 主动控制(设 budget) | 被动控制(架构决定) | 被动控制(模式决定) |
8.9 实战选型决策树
遇到”该选哪种状态管理范式”的问题时,按以下顺序判断:
第一问:你的 Agent 场景有多复杂?
Q1: 你的场景是什么?
A) 对话式 Agent(客服、助手、Copilot)
→ 状态就是对话历史 → Session 追加模式最自然
→ 推荐 tRPC-Agent
B) 流水线式任务(研究→写作→审核→发布)
→ 状态在步骤间线性传递 → Task 输出传递最直觉
→ 推荐 CrewAI
C) 复杂工作流(多维度状态 + 条件分支 + 并行 + 循环)
→ 需要精细的状态分解和并发控制 → Channel 模式最合适
→ 推荐 LangGraph
第二问:你需要什么级别的持久化?
Q2: 你对"中途崩溃"的容忍度?
A) 从头来就行(任务短,几分钟内完成)
→ 不需要 checkpoint → 三者都行
B) 需要从断点恢复(任务长,可能几小时)
→ LangGraph Checkpoint 最强
→ tRPC-Agent Session 持久化也够用
C) 需要 fork/rollback(调试、A/B 测试)
→ 只有 LangGraph 原生支持
第三问:你需要并行执行吗?
Q3: 多个子任务能同时跑吗?
A) 不需要并行(任务有严格顺序依赖)
→ 三者都行,选最简单的
B) 需要并行加速(多路搜索、多 Agent 同时工作)
→ LangGraph 的并行 + Reducer 是唯一原生方案
→ tRPC-Agent 和 CrewAI 需要应用层自理
第四问:你的团队技术水平?
Q4: 团队对 Agent 框架的熟悉度?
A) 新手团队 / 快速原型
→ CrewAI 的 Task 模式认知负担最低
→ tRPC-Agent 的 Session 模式也很直觉
B) 有经验的团队 / 需要精确控制
→ LangGraph 的 Channel 模式提供最大控制力
→ 但需要投入学习 reducer、TypedDict、Graph 编译等概念
第五问:状态维度有多少?
Q5: 你需要同时跟踪几种不同类型的信息?
A) 1-2种(对话历史 + 也许一个计数器)
→ Session 模式绰绰有余
B) 3-5种(历史 + 搜索结果 + 进度 + 偏好 + 队列)
→ Channel 模式的分区存储开始展现价值
C) 5+种,且不同维度有不同的合并/更新逻辑
→ Channel 模式几乎是唯一合理选择
决策总结
简单对话Agent → tRPC-Agent (Session)
流水线任务 → CrewAI (Task Output)
复杂工作流 → LangGraph (Channel)
不确定 → 从 tRPC-Agent 开始(最低成本),复杂度增加时考虑迁移
8.10 高级话题:混合范式
现实中的框架不是”纯粹的”
值得注意的是,成熟的框架往往混合使用多种范式:
tRPC-Agent 的混合:
- 核心是 Session 追加,但 Coordinator 模式中引入了”状态传递”概念(把子 Agent 的输出传回 Coordinator)
- ContextWindowProcessor 隐含了 Channel 思想(把”完整历史”和”LLM视图”分为两层)
LangGraph 的混合:
- Channel 模式中通常有一个
messagesChannel 用operator.add——这本质上是在 Channel 体系内嵌入了 Session 追加模式 - 子图(Subgraph)之间的通信类似 Task 输出传递
CrewAI 的混合:
- Flow 引入了实例变量(类似全局 Channel)
- 内部每个 Agent 执行时仍然是 Session 追加模式(Agent 内部有对话循环)
“没有银弹”的启示
这种混合趋势说明:没有一种范式能完美解决所有状态管理问题。最好的选择是理解每种范式的核心优势和适用边界,在不同层次选择不同范式:
- Agent 内部(单次推理循环):Session 追加最自然
- Agent 之间(多 Agent 协作):Task 输出传递或 Channel 共享
- 工作流层面(整体编排):Channel 类型化提供最大控制力
8.11 本章总结
三个核心认知
-
状态管理是框架的灵魂:选择了哪种范式,就决定了框架能做什么、不能做什么、什么容易什么困难。
-
没有最优,只有最合适:Session 追加简单直觉但不够精细,Channel 类型化强大灵活但学习成本高,Task 输出传递轻量隔离但不够灵活。
-
权衡的本质:简单性 vs 控制力、全量信息 vs 高信噪比、串行安全 vs 并行性能——这些 trade-off 贯穿整个 Agent 框架设计。
一句话记住每种范式
- Session 追加:状态 = 聊天记录,追加不删除,LLM 读全量
- Channel 类型化:状态 = 多列电子表格,每列有自己的合并规则
- Task 输出传递:状态 = 接力棒,上一步的成品传给下一步
思考题
-
设计题:假设你要构建一个”自动论文审稿 Agent”——它需要检查论文的方法论、数据分析、参考文献、格式规范四个方面,每个方面独立检查后汇总出最终审稿意见。你会选择哪种状态管理范式?为什么?如果四个检查可以并行执行呢?
-
权衡题:一个使用 Session 追加模式的客服 Agent,对话经常超过 50 轮。用户反映 Agent 在后期”忘记”了早期讨论的内容。你会怎么设计 ContextWindowProcessor 的组合策略来解决这个问题?要考虑哪些信息是”绝对不能丢”的?
-
迁移题:你的团队最初用 CrewAI 的 Sequential Task 模式构建了一个内容生产 pipeline(研究→大纲→草稿→审核→定稿)。后来需求变了——审核不通过时要循环修改,且研究和大纲可以并行。你需要迁移到什么范式?迁移过程中最大的挑战是什么?
-
分析题:为什么 LangGraph 选择用
Annotated[type, reducer]这种 Python typing 语法来声明 Channel 的 reducer,而不是用更”显式”的方式(比如Channel(type=list, reducer=operator.add))?这个设计选择带来了哪些好处和坏处? -
综合题:对比 LangGraph 的 Checkpoint 和 tRPC-Agent 的 Session 持久化。如果一个 Agent 执行了 100 步后需要恢复到第 50 步重新执行——两种方案分别怎么做?各自的时间/空间/正确性 trade-off 是什么?哪种场景下 tRPC-Agent 的方案反而更优?
上一章:第七章 CrewAI 角色扮演与 Flow 事件驱动
返回目录:Agent 框架深度导读系列