跳到主要内容

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"])

注意三件事,它们贯穿全书:

  1. 动作是普通函数,签名带 state: State,返回新的 State(不是原地改)。
  2. @action(reads=..., writes=...) 把「这个函数碰哪些键」显式声明出来——Burr 会强制校验。
  3. 应用用 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 等都返回 Stateburr/core/state.py
Action(Function+Reducer)一个节点:run 算结果、update 把结果并回状态burr/core/action.py
Condition转移边上的判断,读状态返回 True/Falseburr/core/action.py
Graph / GraphBuilder持有动作 + 转移,负责「给定当前点选下一个动作」burr/core/graph.py
Application运行时:持图 + 持当前 State,跑 step/iterate/runburr/core/application.py
ApplicationBuilder装配一切(图、状态、钩子、持久化、追踪)burr/core/application.py
BaseStatePersister / SQLitePersister把每步状态存/取burr/core/persistence.py
LifecycleAdapterSet + 各 Hook在每步前后回调(追踪、持久化都挂这里)burr/lifecycle/internal.pyburr/lifecycle/base.py
MapStates / MapActionsmap-reduce 子图并行burr/core/parallelism.py

主线走一遍(高层)

以上面的 chatbot 为例,app.run(halt_after=["ai_response"], inputs={...}) 内部:

  1. 选动作:当前没跑过任何动作 → 取 entrypoint,即 human_input(Graph.get_next_node,burr/core/graph.py:150)。
  2. 跑动作:把 inputs 里的 prompt 喂进去,函数返回新 State(promptchat_history 被写)。
  3. 校验 + 并状态:Burr 检查它只写了声明过的键,然后把新状态设为当前状态。
  4. 挂钩子:post_run_step 触发 → 如果配了追踪/持久化,这一步就被记录/存盘。
  5. 再选动作:从 human_input 出发,唯一的边指向 ai_response(默认条件恒真)→ 跑它。
  6. 判停:ai_responsehalt_after 里 → 跑完它就停,返回 (动作, 结果, 最终State)

3. 阅读地图(建议顺序)

按「由浅入深」读:

  1. 01-state-and-actions.md — 先吃透两块基石:State不可变 + delta 操作模型,以及 Action 为什么拆成 Function(算)+ Reducer(并)两半。看完你能读懂任何一个动作。
  2. 02-graph-and-loop.md — 图怎么建、转移条件怎么按顺序选下一步、以及核心循环 _step → iterate → run 的完整链路(含 reads/writes 强制校验、halt 逻辑)。这是 Burr 的「主算法」。
  3. 03-persistence-and-resume.md — 状态如何按 (partition_key, app_id, sequence_id, position) 落库、initialize_from 怎么断点续跑、怎么从历史某点 fork
  4. 04-hooks-streaming.md — 生命周期钩子机制(追踪和持久化都是它的「用户」),以及流式动作怎么一边吐 token 一边在最后给出状态更新。
  5. 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)。