
100行代码搞定多智能体?这个极简AI框架PocketFlow有点东西 原创
现在各种工作流框架太多了,看不过来,也没有什么精力去学习。最近无意中刷到一个微型框架:PocketFlow,这个框架非常小巧,看了下只有 100 行左右的代码,很容易看懂。我非常喜欢,写个教程介绍一下。
对比其他框架
抽象 | 应用特定包装器 | 供应商特定包装器 | 代码行数 | 大小 | |
LangChain | Agent, Chain | 很多 (例如,QA, 摘要) | 很多 (例如,OpenAI, Pinecone等) | 405K | +166MB |
CrewAI | Agent, Chain | 很多 (例如,FileReadTool, SerperDevTool) | 很多 (例如,OpenAI, Anthropic, Pinecone等) | 18K | +173MB |
SmolAgent | Agent | 一些 (例如,CodeAgent, VisitWebTool) | 一些 (例如,DuckDuckGo, Hugging Face等) | 8K | +198MB |
LangGraph | Agent, Graph | 一些 (例如,语义搜索) | 一些 (例如,PostgresStore, SqliteSaver等) | 37K | +51MB |
AutoGen | Agent | 一些 (例如,Tool Agent, Chat Agent) | 很多 [可选] (例如,OpenAI, Pinecone等) | 7K (仅核心) | +26MB (仅核心) |
PocketFlow | Graph | 无 | 无 | 100 | +56KB |
通过对比可以看到,PocketFlow 没有很多融合的功能,只抽象出Graph,就能完成常见的RAG和Agent相关的功能了。
应用场景
PocketFlow 设计了多种应用场景的示例,从基础的聊天机器人到复杂的多智能体系统。以下是一些基础示例:
- 聊天:基础聊天机器人,带有对话历史
- 结构化输出:通过提示从简历中提取结构化数据
- 工作流:写作工作流,包括大纲、内容编写和样式应用
- 智能体:可以搜索网络并回答问题的研究智能体
- RAG:简单的检索增强生成过程
- 批处理:将 Markdown 内容翻译成多种语言的批处理器
更复杂的应用包括:多智能体通信、监督流程、并行执行、思维链推理、短期和长期记忆聊天机器人等。
多智能体实战
现在我们用PocketFlow实现一个AI版"你画我猜"——Taboo游戏
为了直观地展示PocketFlow的威力,我们来看一个用它实现的多智能体协作案例:Taboo(禁忌语)游戏。
游戏规则:
* 提示者 (Hinter): 知道一个目标词和几个"禁忌词"。它的任务是给出提示,引导猜词者猜出目标词,但提示中不能包含任何禁忌词。
* 猜词者 (Guesser): 根据提示者的提示,猜出目标词。
在这个案例中,两个LLM将分别扮演提示者和猜词者,它们需要通过不断的异步通信来协作完成游戏。
PocketFlow使用AsyncNode
来定义异步任务。我们的两个智能体AsyncHinter
和AsyncGuesser
都继承自它。
提示者 AsyncHinter
class AsyncHinter(AsyncNode):
asyncdef prep_async(self, shared):
# 1. 从队列中等待猜词者的消息
guess = await shared["hinter_queue"].get()
if guess == "GAME_OVER":
returnNone
# 2. 准备LLM的输入
return shared["target_word"], shared["forbidden_words"], shared.get("past_guesses", [])
asyncdef exec_async(self, inputs):
# ... (调用LLM生成提示)
target, forbidden, past_guesses = inputs
prompt = f"Generate hint for '{target}'\nForbidden words: {forbidden}"
if past_guesses:
prompt += f"\nPrevious wrong guesses: {past_guesses}\nMake hint more specific."
hint = call_llm(prompt)
print(f"\nHinter: Here's your hint - {hint}")
return hint
asyncdef post_async(self, shared, prep_res, exec_res):
if exec_res isNone:
return"end"
# 3. 将生成的提示放入猜词者的队列
await shared["guesser_queue"].put(exec_res)
return"continue"# 返回Action,驱动流程继续
猜词者 AsyncGuesser
class AsyncGuesser(AsyncNode):
asyncdef prep_async(self, shared):
# 1. 从队列中等待提示者的提示
hint = await shared["guesser_queue"].get()
return hint, shared.get("past_guesses", [])
asyncdef exec_async(self, inputs):
# ... (调用LLM生成猜测)
hint, past_guesses = inputs
prompt = f"Given hint: {hint}, past wrong guesses: {past_guesses}, make a new guess."
guess = call_llm(prompt)
print(f"Guesser: I guess it's - {guess}")
return guess
asyncdef post_async(self, shared, prep_res, exec_res):
# 2. 检查答案
if exec_res.lower() == shared["target_word"].lower():
print("Game Over - Correct guess!")
await shared["hinter_queue"].put("GAME_OVER")
return"end"
# 3. 如果猜错,将错误答案发回给提示者,以便其给出更好的提示
shared.setdefault("past_guesses", []).append(exec_res)
await shared["hinter_queue"].put(exec_res)
return"continue"
两个智能体通过共享存储shared
中的两个asyncio.Queue
(hinter_queue
和guesser_queue
)进行异步通信,一个用于接收信息,一个用于发送信息,实现了完美的解耦。
async def main():
# ... (初始化shared, 包括target_word, forbidden_words, 和两个queue)
# 创建节点和流
hinter = AsyncHinter()
guesser = AsyncGuesser()
hinter_flow = AsyncFlow(start=hinter)
guesser_flow = AsyncFlow(start=guesser)
# 定义循环:当post返回"continue"时,节点会再次执行自己
hinter - "continue" >> hinter
guesser - "continue" >> guesser
# 使用asyncio.gather并发运行两个智能体流
await asyncio.gather(
hinter_flow.run_async(shared),
guesser_flow.run_async(shared)
)
每个智能体都被包装在一个独立的AsyncFlow
中,并通过"continue"
这个Action实现自我循环,不断地接收、处理、发送消息。
核心理念
PocketFlow 把 LLM 工作流抽象为:
+-----------+
Shared | |
Store <--| Node |<-- Params(仅 Batch 用)
+-----------+
|
Action
v
+-----------+
| Node |
+-----------+
- Node:执行 prep → exec → post 三段式。
- Action:post() 返回字符串,决定流向哪一个 successor。
- Flow:负责“根据 Action 走图”的调度器。
- Shared Store:跨 Node 的全局数据约定。
核心源码剖析 (pocketflow/__init__.py)
PocketFlow的强大之处在于其简约的核心抽象。让我们深入其仅有100行的源码,逐一拆解其精妙设计。
BaseNode
BaseNode
是所有节点的基石,它定义了节点最核心的两个属性和两个方法:
* self.successors
: 一个字典,形态为{'action_name': next_node}
。这是PocketFlow流程控制的脉搏。post
方法返回的action
字符串就是在这个字典里查找下一个要执行的节点。
* self.params
: 另一个字典,用于接收外部传入的、节点级别的参数,在批处理场景(Batch)中尤其重要。
* next(self, node, actinotallow="default")
: 这个方法负责填充successors
字典。node_a.next(node_b, "some_action")
就相当于node_a.successors["some_action"] = node_b
。
* _run(self, shared)
: 这是节点的生命周期方法,它严格按照prep -> _exec -> post
的顺序执行,并将prep
的结果传递给_exec
,再将两者的结果传递给post
。
class BaseNode:
def __init__(self): self.params,self.successors={},{}
def set_params(self,params): self.params=params
def next(self,node,actinotallow="default"):
if action in self.successors: warnings.warn(f"Overwriting successor for action '{action}'")
self.successors[action]=node; return node
def prep(self,shared): pass
def exec(self,prep_res): pass
def post(self,shared,prep_res,exec_res): pass
def _exec(self,prep_res): return self.exec(prep_res)
def _run(self,shared): p=self.prep(shared); e=self._exec(p); return self.post(shared,p,e)
def run(self,shared):
if self.successors: warnings.warn("Node won't run successors. Use Flow.")
return self._run(shared)
DSL: >> 和 - 语法糖
PocketFlow中最令人惊艳的莫过于其定义流程的方式,如 node_a - "action" >> node_b
。这其实是巧妙地利用了Python的魔法方法实现的:
* __sub__(self, action)
: 当我们写node_a - "action"
时,Python会调用node_a
的__sub__
方法。这个方法并不做减法,而是返回一个临时的_ConditionalTransition
对象,这个对象保存了node_a
和"action"
。
* __rshift__(self, other)
: 当我们写... >> node_b
时,Python会调用__rshift__
方法。
* 如果直接是node_a >> node_b
,node_a
的__rshift__
被调用,它等价于node_a.next(node_b, "default")
。
* 如果是_ConditionalTransition(...) >> node_b
,那么临时对象的__rshift__
被调用,它执行的是source_node.next(node_b, "saved_action")
。
class BaseNode:
......
def __rshift__(self,other): return self.next(other)
def __sub__(self,action):
if isinstance(action,str): return _ConditionalTransition(self,action)
raise TypeError("Action must be a string")
class _ConditionalTransition:
def __init__(self,src,action): self.src,self.actinotallow=src,action
def __rshift__(self,tgt): return self.src.next(tgt,self.action)
通过这短短几行代码,PocketFlow就创造出了一种极具表现力的领域特定语言(DSL),让流程定义变得像写诗一样自然。
Node
Node
类在BaseNode
的基础上,增加了至关重要的容错机制。
class Node(BaseNode):
def __init__(self,max_retries=1,wait=0): super().__init__(); self.max_retries,self.wait=max_retries,wait
def exec_fallback(self,prep_res,exc): raise exc
def _exec(self,prep_res):
for self.cur_retry in range(self.max_retries):
try: return self.exec(prep_res)
except Exception as e:
if self.cur_retry==self.max_retries-1: return self.exec_fallback(prep_res,e)
if self.wait>0: time.sleep(self.wait)
这里的_exec
方法覆盖了BaseNode
的版本。它不再是简单地调用self.exec
,而是用一个for
循环包裹了try...except
块。
* 循环: max_retries
参数决定了循环次数。
* try
: 尝试执行开发者定义的self.exec(prep_res)
。如果成功,直接返回结果。
* except
: 如果捕获到任何异常,它会检查是否是最后一次重试。
* 如果是,则调用exec_fallback
方法,让开发者有机会进行优雅的失败处理(默认是直接抛出异常)。
* 如果不是,则根据wait
参数等待一段时间后,进入下一次循环重试。
Flow
Flow
是整个工作流的驱动引擎,其核心是_orch
(orchestrate,编排)方法。
class Flow(BaseNode):
def __init__(self,start=None): super().__init__(); self.start_node=start
# ...
def get_next_node(self,curr,action):
nxt=curr.successors.get(action or"default")
ifnot nxt and curr.successors: warnings.warn(f"Flow ends: '{action}' not found in {list(curr.successors)}")
return nxt
def _orch(self,shared,params=None):
curr,p,last_action =copy.copy(self.start_node),(params or {**self.params}),None
while curr:
curr.set_params(p)
last_actinotallow=curr._run(shared)
curr=copy.copy(self.get_next_node(curr,last_action))
return last_action
_orch
的逻辑非常清晰:
- 初始化:
curr
指针指向start_node
。 -
while
循环: 只要curr
不为None
,循环就继续。 - 执行: 调用
curr._run(shared)
来执行当前节点的prep->exec->post
生命周期,并将其post
方法的返回值存为last_action
。 - 寻路: 调用
get_next_node(curr, last_action)
,在当前节点的successors
字典中寻找last_action
对应的新节点。 - 前进: 将
curr
指针更新为找到的新节点。如果找不到,get_next_node
返回None
,循环将在下一次检查时终止。 -
copy.copy()
的使用确保了每个节点的实例在flow的单次运行中是独立的,避免了状态污染。
在我看来,PocketFlow的源码展现了"少即是多"的原则。它没有试图成为一个包罗万象的巨型框架,而是专注于提供一套最核心、最灵活的构建块,将其他的一切(如工具调用、API封装)都交由开发者在节点内部自由实现。
总结
说实话,PocketFlow目前的易用性还有待提升,不如许多框架那样开箱即用。但正是这种精简设计赋予了它更大的灵活性,开发者可以根据自己的需求进行DIY(详细实现方案可参考官方Cookbook:https://github.com/The-Pocket/PocketFlow/tree/main/cookbook)
本文转载自AI 博物院 作者:longyunfeigu
