数据截至 (上游 commit f7989f05195b)
Apache Burr — 架构与原理
30 秒导读: Burr 让你把一个「会做决策的应用」(聊天机器人、agent、模拟器)写成一台显式状态机:每个节点是一个普通 Python 函数(动作),每条边是一次转移(可带条件)。你只声明每个动作读哪些状态键、写哪些状态键,Burr 负责跑循环、把状态当成不可变对象一步步推进、并能随时存盘、断点续跑、实时追踪。它不替你决定怎么调模型、怎么查 API——它只负责把这些串成清晰、可持久、可观测的流程。
1. 这是什么(零基础也能懂)
一句话定义: Burr 是一个轻量、零核心依赖的 Python 库,用来把「带状态的决策逻辑」表达成一台状态机(state machine,即「有限个节点 + 节点间按条件跳转」的流程图)。
它解决谁的什么问题
假设你在写一个客服 agent:用户问一句 → 模型决定要不要查数据库 → 查完再让模型组织回答 → 回答完等用户下一句。这是一个带循环、带分支、带中间状态的流程。
如果用一坨 while True + if/else 手写,你很快会遇到三个老大难:
| 痛点 | 手写时的麻烦 |
|---|---|
| 状态散落 | 对话历史、中间结果、计数器到处是局部变量,难追踪 |
| 断点续跑 | 进程挂了,跑到一半的对话就没了;想「从第 5 步重来」无从下手 |
| 看不见内部 | 出了 bug,不知道当时状态长啥样、走了哪条分支 |
Burr 把这三件事变成框架自带能力:状态集中且不可变、每步可存盘可回放、自带 UI 追踪。
它能做什么
- 用纯 Python 函数定义动作(action),用装饰器声明它读/写哪些状态键。
- 用转移(transition)连接动作,边上可挂条件(condition),决定下一步走哪。
- 跑:
step()(走一步)、iterate()(边走边看)、run()(跑到停)、以及全套async版本。 - 流式输出(LLM token 一个个吐)、并行 map-reduce(同一动作跑在多份状态上)。
- 持久化:每步把状态写进 SQLite / 自定义后端;重启后从上次的地方接着跑,或从任意历史点分叉。
- 追踪:通过生命周期钩子把每步喂给本地 UI / OpenTelemetry。
用起来什么样
这是 README 里的 hello-world(reads/writes 是这个库的灵魂):
from burr.core import action, State, ApplicationBuilder
@action(reads=[], writes=["prompt", "chat_history"])
def human_input(state: State, prompt: str) -> State:
chat_item = {"role": "user", "content": prompt}
return state.update(prompt=prompt).append(chat_history=chat_item)
@action(reads=["chat_history"], writes=["response", "chat_history"])
def ai_response(state: State) -> State:
response = _query_llm(state["chat_history"]) # 你爱怎么调模型都行
chat_item = {"role": "system", "content": response}
return state.update(response=response).append(chat_history=chat_item)
app = (
ApplicationBuilder()
.with_actions(human_input, ai_response)
.with_transitions(("human_input", "ai_response"), ("ai_response", "human_input"))
.with_state(chat_history=[])
.with_entrypoint("human_input")
.build()
)
*_, state = app.run(halt_after=["ai_response"], inputs={"prompt": "Who was Aaron Burr, sir?"})
print("answer:", app.state["response"])
注意三件事,它们贯穿全书:
- 动作是普通函数,签名带
state: State,返回新的 State(不是原地改)。 @action(reads=..., writes=...)把「这个函数碰哪些键」显式声明出来——Burr 会强制校验。- 应用用 builder 链式搭出来,
run时用halt_after说「跑到这个动作后停」。
一句话直觉/类比
把 Burr 当成一台「带存档点的回合制游戏引擎」: 每个动作是一个回合,State 是当前存档(只读快照,每回合产生新存档),转移条件决定下一回合进哪个房间,持久化就是自动存档——你随时能读档继续,或从某个旧档分叉出一条新剧情线。
2. 顶层全景(它大概怎么转)
一张图:从 builder 到执行循环
你写的代码 Burr 内部
┌──────────────┐
│ @action 函数 │──┐
│ (reads/writes)│ │ with_actions / with_transitions / with_entrypoint
└──────────────┘ │ │
▼ ▼
┌─────────────────────────┐ build() ┌───────────────────────┐
│ ApplicationBuilder │────────────▶│ Application │
│ (收集图/状态/钩子/存盘) │ │ (持有 Graph + State) │
└─────────────────────────┘ └───────────┬───────────┘
│ run() / iterate() / step()
▼
┌───────────────────────────────────────────┐
│ 执行循环 (每一步) │
│ ① get_next_action —— 看转移边选下一个动作 │
│ ② _run_function —— 跑动作,拿到结果 dict │
│ ③ _run_reducer —— 把结果 merge 进新 State│
│ ④ 触发 pre/post 钩子 —— 追踪 + 存盘走这里 │
└───────────────────────────────────────────┘
怎么读这张图: 左边是你写的(函数 + 连线),中间 builder 把它们攒成一个 Graph 和初始 State 并造出 Application;右边是每按一步发生的四件事。整本书就是在放大这四步。
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
State | 不可变状态容器;update/append 等都返回新 State | burr/core/state.py |
Action(Function+Reducer) | 一个节点:run 算结果、update 把结果并回状态 | burr/core/action.py |
Condition | 转移边上的判断,读状态返回 True/False | burr/core/action.py |
Graph / GraphBuilder | 持有动作 + 转移,负责「给定当前点选下一个动作」 | burr/core/graph.py |
Application | 运行时:持图 + 持当前 State,跑 step/iterate/run | burr/core/application.py |
ApplicationBuilder | 装配一切(图、状态、钩子、持久化、追踪) | burr/core/application.py |
BaseStatePersister / SQLitePersister | 把每步状态存/取 | burr/core/persistence.py |
LifecycleAdapterSet + 各 Hook | 在每步前后回调(追踪、持久化都挂这里) | burr/lifecycle/internal.py、burr/lifecycle/base.py |
MapStates / MapActions 等 | map-reduce 子图并行 | burr/core/parallelism.py |
主线走一遍(高层)
以上面的 chatbot 为例,app.run(halt_after=["ai_response"], inputs={...}) 内部:
- 选动作:当前没跑过任何动作 → 取
entrypoint,即human_input(Graph.get_next_node,burr/core/graph.py:150)。 - 跑动作:把
inputs里的prompt喂进去,函数返回新 State(prompt和chat_history被写)。 - 校验 + 并状态:Burr 检查它只写了声明过的键,然后把新状态设为当前状态。
- 挂钩子:
post_run_step触发 → 如果配了追踪/持久化,这一步就被记录/存盘。 - 再选动作:从
human_input出发,唯一的边指向ai_response(默认条件恒真)→ 跑它。 - 判停:
ai_response在halt_after里 → 跑完它就 停,返回(动作, 结果, 最终State)。
3. 阅读地图(建议顺序)
按「由浅入深」读:
01-state-and-actions.md— 先吃透两块基石:State的不可变 + delta 操作模型,以及Action为什么拆成Function(算)+Reducer(并)两半。看完你能读懂任何一个动作。02-graph-and-loop.md— 图怎么建、转移条件怎么按顺序选下一步、以及核心循环_step → iterate → run的完整链路(含 reads/writes 强制校验、halt 逻辑)。这是 Burr 的「主算法」。03-persistence-and-resume.md— 状态如何按(partition_key, app_id, sequence_id, position)落库、initialize_from怎么断点续跑、怎么从历史某点 fork。04-hooks-streaming.md— 生命周期钩子机制(追踪和持久化都是它的「用户」),以及流式动作怎么一边吐 token 一边在最后给出状态更新。05-parallelism-and-internals.md— map-reduce 子图并行(MapStates/MapActions)、依赖注入(__context/__tracer)、可借鉴的巧妙设计、以及边界与横向对比。
只想速查某个机制? 直接跳对应章,每章末尾都有「代码地图」(主题 | 文件 | 符号名),可凭符号名 grep 定位源码。
4. 一句话精华(读完你该带走的)
- 状态机是一等公民:不是「LLM 调用链」,而是「显式节点 + 显式边 + 显式状态键」。这让流程可读、可存、可回放。
- 不可变状态 + delta:
State从不被原地改,每次操作产生新 State;内部用StateDelta(set/append/increment…)记录变更,为「事件溯源式历史」留好了口子(见 issue #33)。 - reads/writes 是契约:声明了就会被强制——动作写了没声明的键直接报错。这把「状态被偷偷改坏」挡在门外。
- 追踪 / 持久化 / 并行都是钩子或动作的特例:核心循环很小,扩展能力靠生命周期钩子(
pre/post_run_step)和把子应用塞进一个动作(parallelism)。