教程:从一个任务到可恢复的模型评测¶
English | 简体中文
这是一份循序渐进的 pyattacker 入门指南——从五行程序,一路讲到可恢复、可分片的模型评测。
你是谁: 用 Python 跑 LLM 或 agent 评测,熟悉 asyncio,想要的是底层基建——端点资源池、并发控制、重试机制、"哪些行已经跑过了",以及每一次请求的完整记录。
怎么读:
- 每一步都是一个完整程序。先跑起来,看输出,再读解释。
- 以
# tutorial/step_NN_....py开头的代码块会被自动抽取成独立文件,在每次测试时由tests/test_tutorial.py实际执行。把其中一个保存成注释里指定的文件名,直接运行即可。 - 所有示例都不访问网络。真实程序里该写
await self.http.post(...)的地方,示例改用await asyncio.sleep(...)——注释# <- your HTTP call标出了该位置。
其他文档: docs/reference.md 是完整 API 参考;
docs/cli.md 讲命令行用法;docs/design.md 解释框架为什么长这样;examples/ 有完整可运行的示例。
找到你需要的内容¶
第一次按顺序读。之后用这张表快速定位。
| 我想要…… | 步骤 | 参考 |
|---|---|---|
| 写第一个任务并跑起来 | 第 1 步 | 任务 |
| 跑整个数据集,或每行取 k 个样本 | 第 2 步 | map |
| 把多个步骤串起来 | 第 3 步 | 流水线 |
| 了解会保存什么、什么时候保存 | 第 4 步 | 工件 |
| 把负载分散到多个端点或 key | 第 5 步 | 资源 |
| 所有资源都忙的时候怎么等 | 第 6 步 | 算法 |
| 安全地使用资源 | 第 7 步 | Lease |
| 重试失败,并搞清楚为什么放弃 | 第 8 步 | Retrying、错误 |
| 崩溃后接着跑,不重复花钱 | 第 9 步 | Runner |
| 查询记录、导出结果 | 第 10 步 | 存储、导出 |
| 用 YAML 和 CLI 驱动 | 第 11 步 | docs/cli.md |
| 用多个进程跑 | 第 12 步 | 分片 |
| 在步骤内分支 | 第 13 步 | fanout |
| 存自定义类型或大载荷,发插件 | 第 14 步 | 编解码器、后端、插件 |
| 监控正在跑的运行 | 第 14 步 | 监控 |
| 在任务内部跳过链中剩余部分 | 第 15 步 | 交接 |
| 把工作送回更早的站点 | 第 16 步 | 反向遍历 |
| 在载荷内部快照与恢复状态 | 第 17 步 | HistoryArtifact |
第 0 步 —— 安装与健全性检查¶
uv sync # or: python -m venv .venv && .venv/bin/pip install -e .
uv run pyattacker demo # zero-config smoke test: 50 simulated pipelines, retries, a report
uv run pytest -q # the whole suite, offline, a few seconds
pyattacker demo 是最快的方式——看一眼一次运行长什么样:
环境要求:Python 3.11+,没了——基础安装零依赖。第 11 步会读 YAML 配置,那是唯一需要额外安装的地方(uv add "pyattacker[yaml]");其他步骤什么都不用装。
第 1 步 —— 一个种子、一个任务、一次运行¶
# tutorial/step_01_smallest.py
"""Step 1 - the smallest useful program: one seed, one task, one persisted result."""
from pyattacker import Runner, pipeline, task
@task("double")
def double(seed: dict) -> dict:
"""A task is a unary function: one artifact in, one artifact out."""
return {"n": seed["n"], "doubled": seed["n"] * 2}
template = pipeline("doubling", double)
with Runner(store="runs/step01.db", concurrency=2, label="step-1") as runner:
report = runner.run(template.map([{"n": 21}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
print(f"pipeline {row['pipeline_id'][:12]} state={row['state']} "
f"tasks={row['n_tasks_done']}/{row['n_tasks_total']}")
print("final artifact:", row["artifacts"][-1]["payload"])
run run-... status=completed wall=0.00s
pipelines: total=1 succeeded=1
pipeline latency ms: p50=0.487 p95=0.487 max=0.487
attempts: total=1
tasks: double=1
pipeline b3265e1db9b8 state=succeeded tasks=1/1
final artifact: {'doubled': 42, 'n': 21}
五个概念,就是全部词汇:
| 概念 | 在本程序中 | 它是什么 |
|---|---|---|
| 工件(artifact) | {"n": 21, "doubled": 42} |
任务持久化后的输出,一产出就写盘 |
| 任务(task) | double |
一元函数 (artifact) -> artifact;同步异步都行,不用继承任何基类 |
| 流水线(pipeline) | pipeline("doubling", double) |
任务的线性链;完成和恢复的单位 |
| 种子(seed) | [{"n": 21}] |
数据集里的一行;template.map(seeds) 给每行生成一条流水线 |
| 运行器(runner) | Runner(store=..., concurrency=2) |
调度器:管着存储、资源池和 worker 槽位 |
三件事现在就得知道:
- 任务只接受一个参数;如果需要框架上下文,就接受两个(
value, ctx)。多一个少一个装饰时都会报ConfigError。额外状态放进闭包就行。 Runner是上下文管理器,退出with块就关存储。要读基于文件的存储,就在with块里读,或者之后重新打开那个文件(第 10 步讲)。内存存储没这问题。store=":memory:"是默认值,测试时就用它;给个路径就是持久化的 SQLite 文件。
第 2 步 —— 一次处理多个输入¶
流水线模板是可复用的:map() 把一串种子变成一批语义上完全独立的流水线。
# tutorial/step_02_map.py
"""Step 2 - one template, many inputs: map() turns a stream of seeds into independent pipelines."""
import asyncio
import time
from pyattacker import Runner, pipeline, task
def dataset(n: int):
"""Any iterable works; a generator keeps memory flat no matter how large the dataset is."""
for i in range(n):
yield {"qid": f"q{i:02d}", "value": i}
@task("square")
async def square(row: dict) -> dict:
await asyncio.sleep(0.02) # <- in real life: one HTTP request
return {"qid": row["qid"], "squared": row["value"] ** 2}
template = pipeline("squares", square)
with Runner(store="runs/step02.db", concurrency=4, label="step-2") as runner:
started = time.perf_counter()
report = runner.run(template.map(dataset(12)))
elapsed = time.perf_counter() - started
print(report.summary())
print(f"12 pipelines x 20ms of work, concurrency=4 -> {elapsed:.2f}s wall clock")
# pass@k: one seed expands into k independent pipelines, each with its own checkpoint.
for spec in template.map([{"qid": "q00", "value": 0}], repeats=3):
print(f" repeat={spec.repeat} key={spec.key[:12]}")
run run-... status=completed wall=0.07s
pipelines: total=12 succeeded=12
pipeline latency ms: p50=20.734 p95=21.868 max=22.085
attempts: total=12
tasks: square=12
12 pipelines x 20ms of work, concurrency=4 -> 0.07s wall clock
repeat=0 key=b8c5e6d11da5
repeat=1 key=418c035f6b74
repeat=2 key=055e9cb3f519
map()接受任何可迭代对象,包括生成器,而且是惰性的。内存占用保持在 O(concurrency)——一千万行的数据集和十行数据集,内存开销一样。concurrency=4指同时有四个在飞的尝试,不是四条活的流水线——流水线在等重试退避的时候不占 worker 槽位(第 8 步讲)。- 任务体必须真的
await点什么,工作才会重叠。并发 4 的时候,12 × 20ms 大概 0.07s 跑完;如果任务把事件循环堵死,就得 0.24s。 repeats=3就是 pass@k 和自洽采样:一个种子、三条流水线、三个独立检查点。用key_of=lambda row: row["qid"]可以自己指定 id,不用默认的内容寻址。spec.key(和pipeline_id是同一个值)来自任务链摘要 + 种子内容 + 重复序号,所以重跑同一份数据集,id 完全一样——恢复和分片就靠这个。
第 3 步 —— 串联任务¶
a | b | c 就是一条流水线。每个任务的工件就是下一个任务的输入,链在构建时校验,不是跑到一半才报错。
# tutorial/step_03_chain.py
"""Step 3 - chaining: prepare | answer | judge. Each task's artifact is the next task's input."""
from pyattacker import PipelineBuildError, Runner, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"question": seed["q"], "context": ["doc-1", "doc-2"]}
@task("answer")
def answer(row: dict) -> dict:
return {**row, "answer": f"answer to {row['question']!r}"}
@task("judge")
def judge(row: dict) -> dict:
return {"question": row["question"], "answer": row["answer"], "score": len(row["answer"]) % 5}
@task("expects_int")
def expects_int(value: int) -> int:
return value
# The chain is validated where it is built, not in the middle of a run:
try:
pipeline("broken", prepare | expects_int)
except PipelineBuildError as exc:
print("rejected at construction time:", exc)
template = pipeline("rag", prepare | answer | judge, tags={"example": "step-3"})
with Runner(store="runs/step03.db", concurrency=2, label="step-3") as runner:
report = runner.run(template.map([{"q": "why is the sky blue?"}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
print("tasks:")
for item in row["tasks"]:
print(f" seq={item['seq']} {item['name']:<8} {item['state']:<9} "
f"attempts={item['attempts_used']} duration_ms={round(item['duration_ms'], 3)}")
print("artifacts (the seed, then one per task):")
for item in row["artifacts"]:
print(f" seq={item['seq']:>2} {item['task']:<10} digest={item['digest'][:12]} "
f"payload={item['payload']!r}")
print("final artifact:", row["artifacts"][-1]["payload"])
rejected at construction time: artifact types do not chain: task 'prepare' produces dict, but task 'expects_int' requires int
run run-... status=completed wall=0.00s
pipelines: total=1 succeeded=1
...
tasks:
seq=0 prepare succeeded attempts=1 duration_ms=0.016
seq=1 answer succeeded attempts=1 duration_ms=0.005
seq=2 judge succeeded attempts=1 duration_ms=0.004
artifacts (the seed, then one per task):
seq=-1 __seed__ digest=ce5d39232b24 payload={'q': 'why is the sky blue?'}
seq= 0 prepare digest=0ad2b84efc93 payload={'context': ['doc-1', 'doc-2'], 'question': 'why is the sky blue?'}
seq= 1 answer digest=c6fbc3132821 payload={'answer': "answer to 'why is the sky blue?'", 'context': [...], 'question': '...'}
seq= 2 judge digest=ffc919adcb7e payload={'answer': "answer to 'why is the sky blue?'", 'question': '...', 'score': 2}
final artifact: {'answer': "answer to 'why is the sky blue?'", 'question': 'why is the sky blue?', 'score': 2}
- 链的校验看的是类型注解:返回
Question的任务可以接到接受Question的任务后面(子类也行),Any或没注解不做检查,裸容器可以接参数化形式(dict←dict[str, Any])。不匹配就在构建流水线时抛PipelineBuildError。 seq是任务在链里的位置。seq=-1是种子:数据集那行本身也存成了工件——所以恢复的时候不需要原始数据集文件也能跑(第 9 步讲)。- 最终工件是最后一个任务的输出,标了
is_final=True。 - 流水线是线性的。真要分支——三个评委、k 个样本、多个指标——用
fanout(...)把分支放在单个任务内部(第 13 步讲)。
第 4 步 —— 检查点就是工件¶
每个任务成功就立刻把自己的工件落盘,所以检查点的粒度就是任务。
# tutorial/step_04_checkpoint.py
"""Step 4 - what a checkpoint is: one content-addressed artifact per task, durable immediately."""
from dataclasses import dataclass
from pyattacker import Runner, pipeline, task
@dataclass
class Answer:
qid: str
text: str
@task("fetch")
def fetch(seed: dict) -> dict:
return {"qid": seed["qid"], "question": seed["question"]}
@task("answer")
def answer(row: dict) -> Answer:
"""An annotated type is registered automatically, so a restored checkpoint is an Answer again."""
return Answer(qid=row["qid"], text=f"answer to {row['question']!r}")
@task("score")
def score(ans: Answer) -> dict:
return {"qid": ans.qid, "score": len(ans.text) % 5}
template = pipeline("qa", fetch | answer | score, tags={"stage": "tutorial"})
rows = [{"qid": "q1", "question": "why?"}, {"qid": "q2", "question": "how?"}]
with Runner(store="runs/step04.db", concurrency=2) as runner:
first = runner.run(template.map(rows))
print("first run: ", first.stats["pipelines"]["by_state"])
# Same seeds + same task source -> the same content-addressed keys -> nothing runs again.
again = runner.run(template.map(rows))
print("second run: skipped =", again.skipped,
"(a skipped pipeline is not rewritten, so it still belongs to the first run)")
row = next(iter(runner.store.export_rows()))
print(f"\npipeline {row['pipeline_id'][:12]} tags={row['tags']}")
for item in row["artifacts"]:
print(f" seq={item['seq']:>2} {item['task']:<10} {item['type']:<7} codec={item['codec']:<5} "
f"digest={item['digest'][:10]} final={item['is_final']} payload={item['payload']!r}")
checkpoint = runner.store.get_artifact(row["pipeline_id"], 1)
restored = runner.registry.load(checkpoint.encoded())
print("checkpoint at seq=1 restores as:", type(restored).__name__, restored)
@task("score")
def score_changed(ans: Answer) -> dict:
"""Same name, different body: the source digest changes, so this is a *different* pipeline."""
return {"qid": ans.qid, "score": len(ans.text) % 7}
changed = pipeline("qa", fetch | answer | score_changed, tags={"stage": "tutorial"})
print("\nchanged task source -> new pipeline:", template.spec_digest != changed.spec_digest)
print("old spec_digest:", template.spec_digest[:16], "new spec_digest:", changed.spec_digest[:16])
first run: {'succeeded': 2}
second run: skipped = 2 (a skipped pipeline is not rewritten, so it still belongs to the first run)
pipeline 46122647cecb tags={'stage': 'tutorial'}
seq=-1 __seed__ dict codec=json digest=354ba03eb6 final=False payload={'qid': 'q1', 'question': 'why?'}
seq= 0 fetch dict codec=json digest=354ba03eb6 final=False payload={'qid': 'q1', 'question': 'why?'}
seq= 1 answer Answer codec=json digest=a14ab1a820 final=False payload={'qid': 'q1', 'text': "answer to 'why?'"}
seq= 2 score dict codec=json digest=29d44d6ed5 final=True payload={'qid': 'q1', 'score': 1}
checkpoint at seq=1 restores as: Answer Answer(qid='q1', text="answer to 'why?'")
changed task source -> new pipeline: True
old spec_digest: 5808fe46061bad55 new spec_digest: 01b60826ed0465ea
每个任务成功时,按顺序发生四件事:工件字节写盘、任务行记录完成、n_tasks_done 往前推,然后下一个任务才启动。
- 内容寻址。 这里
seq=-1和seq=0是同一个摘要,因为fetch原样返回了种子——相同的载荷就是相同的字节,只存一份。工件的身份是(pipeline_id, seq)。 - 数据类能往返。 标注返回类型(
-> Answer)就会自动注册这个类,所以恢复出来的检查点是Answer,不是dict。二进制类型就注册编解码器(第 14 步讲)。 - 改了任务代码,就是新流水线。 流水线 key 包含每个任务源码的摘要,所以你改了任务体,旧检查点就不会被复用——不会拿不同代码的结果当自己的。想让任务体变了还复用检查点,就给
pipeline(...)传include_code=False;工厂参数、子任务和声明的策略还是会影响身份。手动指定的 key 会拒绝任务/输入不匹配的情况。v2 存储升级和外部副作用幂等性,见恢复同一性。 - 重跑同一份种子等于没跑。 第二次运行报
skipped=2。被跳过的流水线不会被改写,它还是属于第一次真正跑了它的那次运行。
第 5 步 —— 端点作为资源池¶
Resource 是一项具体能力(某个端点、某把 key、某个本地 worker)。Pool 就是一组这样的资源,加上一套等待策略。任务通过 ctx.acquire(...) 拿其中一个。
# tutorial/step_05_pool.py
"""Step 5 - endpoints as a pool: a factory makes a client, capacity bounds concurrency, selectors route."""
import asyncio
from pyattacker import Pool, Resource, Runner, pipeline, task
class Client:
"""The shape your real provider client should have: built from resource.options, owned by the pool."""
def __init__(self, options: dict) -> None:
self.model = options["model"]
async def chat(self, prompt: str) -> str:
await asyncio.sleep(0.01) # <- your HTTP call
return f"[{self.model}] {prompt}"
def build_client(resource: Resource) -> Client:
return Client(resource.options)
@task("ask", resource="apis", timeout_s=5)
async def ask(row: dict, ctx) -> dict:
model = "gpt-4o" if row["hard"] else "gpt-4o-mini"
async with ctx.acquire(model=model) as lease: # only a matching resource is handed out
text = await lease.client.chat(row["question"])
lease.report(ok=True, latency_ms=10, usage={"tokens": len(text)})
return {"question": row["question"], "answer": text, "served_by": lease.resource.id}
pool = Pool(
"apis",
[
Resource.create("llm", id="api-a", capacity=2, options={"model": "gpt-4o"}, factory=build_client),
Resource.create("llm", id="api-b", capacity=2, options={"model": "gpt-4o"}, factory=build_client),
Resource.create("llm", id="api-c", capacity=4, options={"model": "gpt-4o-mini"}, factory=build_client),
],
)
rows = [{"question": f"q{i}", "hard": i % 2 == 0} for i in range(6)]
with Runner(store="runs/step05.db", pools=[pool], concurrency=8) as runner:
report = runner.run(pipeline("qa", ask).map(rows))
print(report.summary())
print("resource state (capacity is per resource, so this pool can hand out 8 leases at once):")
for slot in pool.snapshot():
print(f" {slot['id']:<6} state={slot['state']:<7} leases={slot['leases']} "
f"active_now={slot['active']}/{slot['capacity']}")
stats = pool.stats()
print(f"pool totals: leases={stats.leases_total} ok={stats.ok_total} "
f"waiting={stats.waiting} utilization={stats.utilization}")
print("usage reported through lease.report():", stats.usage)
run run-... status=completed wall=0.02s
pipelines: total=6 succeeded=6
...
resource state (capacity is per resource, so this pool can hand out 8 leases at once):
api-a state=ready leases=2 active_now=0/2
api-b state=ready leases=1 active_now=0/2
api-c state=ready leases=3 active_now=0/4
pool totals: leases=6 ok=6 waiting=0 utilization=0.0
usage reported through lease.report(): {'tokens': 81.0}
capacity是资源级别的,不是资源池级别的:api-a和api-b各允许 2 个并发,api-c允许 4 个,所以这个池子一共能同时发 8 个请求。把这个总数和concurrency比一下——worker 比容量多,就意味着 worker 要在资源池上排队。factory=每个资源只调一次,懒执行,第一次租约的时候才调;之后这个资源的所有租约共享同一个 client 对象。你的 SDK client 或连接池就放这里。如果 factory 抛异常,框架会用ResourceUnavailable拒绝这次租约(绝不会给你一个client是None的租约),记一条resource.factory_failed事件;反复失败最终会把这个资源标成dead。- 选择器(
ctx.acquire(model="gpt-4o-mini"))按options、tags、id、kind匹配,也支持用点号路径深入嵌套 options("quota.tokens")——这样才能路由到对的 client。 - 资源池的所有信息都来自
lease.report(...):ok=False用来熔断,latency_ms维护 EMA,usage={"tokens": n}累积配额供排序用(第 6 步讲)。 pool.snapshot()是逐资源视图,pool.stats()是聚合视图。运行期间调用都是安全的。
坑: 用默认的
wait算法时,选择器匹配不到任何资源会永久卡死。要么确保每个选择器都有对应资源,要么传timeout=/ 用algorithm="immediate"——这样至少会报错,不会干等着。
第 6 步 —— 如何选择等待方式¶
怎么拿资源是策略问题,怎么用资源是你的代码。获取算法可以按任务设,也可以按资源池设。
# tutorial/step_06_algorithms.py
"""Step 6 - how a task waits for a resource is a policy, orthogonal to what the task does."""
import asyncio
from pyattacker import Pool, Resource, Runner, pipeline, task
def make_pool(name: str, algorithm: str, *, resources: int = 1, capacity: int = 1,
options: dict | None = None) -> Pool:
return Pool(
name,
[
Resource.create("llm", id=f"{name}-{i}", capacity=capacity,
options=dict(options or {"model": "m"}))
for i in range(resources)
],
algorithm=algorithm,
)
def experiment(algorithm: str, rows: list, *, resources: int = 1, capacity: int = 1,
concurrency: int = 4):
"""One task body, one pool, four acquire policies."""
@task("ask", resource=f"p-{algorithm}", algorithm=algorithm)
async def ask(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
await asyncio.sleep(0.02) # the request
return {"i": row["i"], "resource": lease.resource.id}
pool = Pool(f"p-{algorithm}", [
Resource.create("llm", id=f"p-{algorithm}-{i}", capacity=capacity, options={"model": "m"})
for i in range(resources)
], algorithm=algorithm)
with Runner(store=f"runs/step06_{algorithm}.db", pools=[pool], concurrency=concurrency) as runner:
report = runner.run(pipeline(f"t-{algorithm}", ask).map(rows))
error_types = sorted({e["error_type"] for e in runner.store.errors()})
return report, pool, error_types
rows4 = [{"i": i} for i in range(4)]
# immediate: fail fast when the pool is saturated (note: ResourceUnavailable is classified "unknown",
# so the default policy does not retry it - add retry_unknown=True if you want capacity retries)
report, _, error_types = experiment("immediate", rows4)
print("immediate :", report.stats["pipelines"]["by_state"], "-> errors:", error_types)
# wait (the default): queue up until a slot frees
report, _, _ = experiment("wait", rows4)
print("wait :", report.stats["pipelines"]["by_state"], "(4 x 20ms of work on a single slot)")
# least_busy: spread over the resources instead of piling onto the first free one
report, pool, _ = experiment("least_busy", [{"i": i} for i in range(6)],
resources=2, concurrency=2)
print("least_busy:", report.stats["pipelines"]["by_state"],
"leases per resource:", {s["id"]: s["leases"] for s in pool.snapshot()})
# quota_aware: prefer the resource with the most quota left (declared in options, consumed via report)
pool = make_pool("p-quota", "quota_aware", resources=2)
pool.resources()[0].options["quota"] = {"tokens": 1000}
pool.resources()[1].options["quota"] = {"tokens": 100_000}
@task("budgeted", resource="p-quota", algorithm="quota_aware")
async def budgeted(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=True, usage={"tokens": 1000})
return {"i": row["i"], "resource": lease.resource.id}
with Runner(store="runs/step06_quota.db", pools=[pool], concurrency=1) as runner:
runner.run(pipeline("t-quota", budgeted).map([{"i": i} for i in range(3)]))
picked = [row["artifacts"][-1]["payload"]["resource"] for row in runner.store.export_rows()]
print("quota_aware:", picked, "(roomiest first, then the top-up, then the exhausted one again)")
# sticky: a pipeline keeps the resource it already used (prompt caches, warm connections)
pool = make_pool("p-sticky", "sticky", resources=2)
@task("two_calls", resource="p-sticky", algorithm="sticky")
async def two_calls(row: dict, ctx) -> dict:
async with ctx.acquire() as first:
first_id = first.resource.id
async with ctx.acquire() as second:
return {"first": first_id, "second": second.resource.id, "same": first_id == second.resource.id}
with Runner(store="runs/step06_sticky.db", pools=[pool], concurrency=1) as runner:
runner.run(pipeline("t-sticky", two_calls).map([{"i": 0}]))
payload = next(iter(runner.store.export_rows()))["artifacts"][-1]["payload"]
print("sticky :", payload)
immediate : {'failed': 3, 'succeeded': 1} -> errors: ['ResourceUnavailable']
wait : {'succeeded': 4} (4 x 20ms of work on a single slot)
least_busy: {'succeeded': 6} leases per resource: {'p-least_busy-0': 3, 'p-least_busy-1': 3}
quota_aware: ['p-quota-1', 'p-quota-0', 'p-quota-1'] (roomiest first, then the top-up, then the exhausted one again)
sticky : {'first': 'p-sticky-0', 'same': True, 'second': 'p-sticky-0'}
| 算法 | 没空闲资源时的行为 | 什么时候用 |
|---|---|---|
wait (默认) |
排队等槽位释放或 timeout= 超时 |
稳态负载,更看重吞吐而不是延迟 |
backoff |
指数退避 + 抖动(base、factor、cap、max_wait) |
接口方已经饱和,避免释放时大家一起冲 |
least_busy |
选负载比例最低的资源,否则退化为等待 | 多个容量不一样的端点 |
failover |
按顺序试一组资源池,然后回退 | 主备接口方,不同层用不同 key |
sticky |
优先用这条流水线已经用过的资源 | prompt/前缀缓存、热连接、粘性会话 |
quota_aware |
先按剩余比例排,再按绝对余量排 | 预算有限的端点,options={"quota": {"tokens": N}} |
immediate |
立刻抛 ResourceUnavailable |
宁可丢负载也不想排队 |
输出里有两个点值得注意:
immediate让 4 条流水线挂了 3 条。ResourceUnavailable归类成unknown,默认重试策略不重试unknown。想让容量问题也重试,就设Retrying(max_attempts=3, retry_unknown=True)。quota_aware先选了p-quota-1(两个都是新的,绝对余量大的平局胜出),然后是p-quota-0(还没用过,比例更优),等那个 1000 token 的资源用完了又回到p-quota-1。配额只是偏好,不是硬停——要硬限制就自己在任务里跟踪预算然后抛错。
获取时的退避(backoff)和重试时的退避(Retrying)是两个独立的旋钮:前者决定等槽位等多久,后者决定失败后等多久。
第 7 步 —— 租约契约¶
async with ctx.acquire(...) 在任何退出路径上都会归还资源。这一步把每条路径都走一遍。
# tutorial/step_07_lease_safety.py
"""Step 7 - the lease contract: every exit path returns the resource, or the framework reclaims it and says so."""
import asyncio
from pyattacker import Pool, Resource, Retrying, Runner, pipeline, task
def make_pool() -> Pool:
return Pool("apis", [Resource.create("llm", id="api-1", capacity=1)], algorithm="wait")
def event_kinds(store) -> list[str]:
return [e.kind for e in store.events(limit=100) if e.kind.startswith(("resource.", "lease."))]
# 1) an exception inside the block
@task("raises_while_holding", resource="apis", retry=Retrying(max_attempts=1))
async def raises_while_holding(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=False, error="boom")
raise RuntimeError("the request blew up") # __aexit__ still runs and still returns the lease
pool = make_pool()
with Runner(store="runs/step07a.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("raises", raises_while_holding).map([{"i": 0}]))
print("1) exception:", report.stats["pipelines"]["by_state"],
"| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active, "ready =", pool.stats().ready)
# 2) the escape hatch, with the release forgotten
@task("forgets_to_release", resource="apis")
async def forgets_to_release(row: dict, ctx) -> dict:
lease = await ctx.acquire_lease() # tracked by ctx, but not returned here
return {"held": lease.resource.id}
pool = make_pool()
with Runner(store="runs/step07b.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("leaky", forgets_to_release).map([{"i": 0}]))
print("\n2) forgotten release:", report.stats["pipelines"]["by_state"],
"| leases_leaked =", report.leases_leaked, "| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active,
"(the task still succeeded - a leak is reported, not hidden)")
# 3) the same leak, with strict_leases on
pool = make_pool()
with Runner(store="runs/step07c.db", pools=[pool], concurrency=1, strict_leases=True) as runner:
report = runner.run(pipeline("leaky", forgets_to_release).map([{"i": 0}]))
print("\n3) strict_leases:", report.stats["pipelines"]["by_state"],
"| error:", sorted({e["error_type"] for e in runner.store.errors()}),
"| leases_leaked =", report.leases_leaked)
# 4) cancellation by timeout_s
@task("too_slow", resource="apis", timeout_s=0.01)
async def too_slow(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
lease.report(ok=True)
await asyncio.sleep(5) # cancelled by timeout_s; the lease is returned on the way out
return {"never": "reached"}
pool = make_pool()
with Runner(store="runs/step07d.db", pools=[pool], concurrency=1) as runner:
report = runner.run(pipeline("slow", too_slow).map([{"i": 0}]))
attempt = runner.store.attempts()[0]
print("\n4) timeout_s:", report.stats["pipelines"]["by_state"],
"| error_class =", attempt.error_class, "| events:", event_kinds(runner.store))
print(" pool afterwards: active =", pool.stats().active,
"(cancellation cannot strand a resource)")
1) exception: {'failed': 1} | events: ['resource.leased', 'resource.released']
pool afterwards: active = 0 ready = 1
2) forgotten release: {'succeeded': 1} | leases_leaked = 1 | events: ['resource.leased', 'lease.leaked', 'resource.leaked']
pool afterwards: active = 0 (the task still succeeded - a leak is reported, not hidden)
3) strict_leases: {'failed': 1} | error: ['LeaseLeakError'] | leases_leaked = 1
4) timeout_s: {'failed': 1} | error_class = timeout | events: ['resource.leased', 'resource.released']
pool afterwards: active = 0 (cancellation cannot strand a resource)
| 场景 | 保证 |
|---|---|
async with ctx.acquire(...) 正常退出 |
退出时同步归还 |
| 块里抛异常 | 照样归还;__aexit__ 会执行,也不吞异常 |
| 循环里 acquire → use → release | 每轮都归还,真正让出并发 |
timeout_s 超时、运行被取消、Ctrl-C |
finally 里同步归还 |
await ctx.acquire_lease() 后忘了还 |
任务结束时强制回收,记 lease.leaked + resource.leaked |
| 任务返回了还占着租约 | 不可能——回收在写任务行之前就做了 |
你该怎么做:
- 用
async with ctx.acquire(...) as lease:,而且只在请求期间拿着租约。长时间持有是合法的,但那就是让资源池饥饿的原因。 - 忘了还会被报告,不会被藏着:查
report.leases_leaked,盯lease.leaked事件,CI 里开strict_leases=True——直接让任务失败,不只是报告。 - 从一个资源池拿了资源,又向同一个资源池申请第二个,这是一种死锁形态。资源池会在
deadlock_warn_s(默认 5s)后发acquire.suspected_deadlock,不会安静挂着。要么一开始就把两个都拿到,要么用两个资源池。
第 8 步 —— 失败:分类、重试、记录¶
失败就是普通的 Python 异常。框架会给它分类,问你的策略怎么办,然后把每一次尝试和每一个决策都记下来。
# tutorial/step_08_retry.py
"""Step 8 - failure handling: classify once, retry by policy, keep the decision in the record."""
from pyattacker import (
FatalError,
Pool,
Resource,
RetryableError,
Retrying,
Runner,
error_class_of,
pipeline,
task,
)
class HttpError(Exception):
def __init__(self, status: int) -> None:
super().__init__(f"HTTP {status}")
self.status = status
print("classification is one pure function over any exception:")
for exc in (HttpError(429), HttpError(503), HttpError(404), TimeoutError(),
ConnectionError("reset"), ValueError("bad json"), Exception("?")):
print(f" {type(exc).__name__:<16}{str(exc):<12} -> {error_class_of(exc)}")
CALLS = {"n": 0}
@task("ask", resource="apis", retry=Retrying(max_attempts=4, base=0.01, cap=0.05))
async def ask(row: dict, ctx) -> dict:
CALLS["n"] += 1
async with ctx.acquire() as lease:
if ctx.attempt <= 2:
lease.report(ok=False, error="429")
# A retryable error carries its own class, and retry_after is a server-suggested delay.
raise RetryableError("rate limited", error_class="rate_limit", retry_after=0.01)
lease.report(ok=True)
return {"i": row["i"], "attempts": ctx.attempt}
@task("always_fatal", resource="apis", retry=Retrying(max_attempts=5))
def always_fatal(row: dict) -> dict:
raise FatalError("the request itself is invalid; another attempt cannot help")
fatal_specs = list(pipeline("fatal", always_fatal).map([{"i": 9}]))
with Runner(store="runs/step08.db", pools=[Pool("apis", [Resource.create("llm", id="api-1", capacity=4)])],
concurrency=2) as runner:
report = runner.run(pipeline("retrying", ask).map([{"i": 1}]))
print("\nretried pipeline:", report.stats["pipelines"]["by_state"],
"| requests made:", CALLS["n"])
for attempt in runner.store.attempts():
print(f" attempt {attempt.attempt_no} {attempt.outcome:<9} class={attempt.error_class or '-':<11} "
f"delay_s={attempt.retry_delay_s} why={attempt.decision.get('reason')}")
fatal = runner.run(iter(fatal_specs))
attempts = runner.store.attempts(pipeline_id=fatal_specs[0].pipeline_id)
print("fatal pipeline: ", fatal.stats["pipelines"]["by_state"],
f"| attempts used: {len(attempts)} of max_attempts=5 ->", attempts[0].error_class)
classification is one pure function over any exception:
HttpError HTTP 429 -> rate_limit
HttpError HTTP 503 -> upstream
HttpError HTTP 404 -> fatal
TimeoutError -> timeout
ConnectionError reset -> connection
ValueError bad json -> invalid
Exception ? -> unknown
retried pipeline: {'succeeded': 1} | requests made: 3
attempt 1 failed class=rate_limit delay_s=0.01 why=retryable
attempt 2 failed class=rate_limit delay_s=0.01 why=retryable
attempt 3 succeeded class=- delay_s=None why=ok
fatal pipeline: {'failed': 1} | attempts used: 1 of max_attempts=5 -> fatal
默认重试:retryable、rate_limit、timeout、connection、upstream。默认不重试:
invalid、fatal、cancelled、unknown。分类会读 TimeoutError、ConnectionError、error_class 属性,以及 status / status_code / http_status / code 上的 HTTP 状态码(回退到 exc.response.status_code)——覆盖了常见的接口方 SDK,不用 import 它们。完整表格见参考文档。
你最常用的 Retrying 字段:
| 字段 | 默认值 | 意思 |
|---|---|---|
max_attempts |
1 |
总尝试次数——默认不重试 |
on |
() |
额外视为可重试的异常类型 |
retry_unknown |
False |
也重试 unknown,比如 ResourceUnavailable |
base、factor、cap |
0.5、2.0、30.0 |
指数退避的上下界 |
max_total_s |
None |
尝试次数 + 延迟超过这个预算就放弃 |
引导它的方式:
raise RetryableError("rate limited", error_class="rate_limit", retry_after=0.01)——带上错误类别,还遵守服务端建议的延迟。异常暴露Retry-After头时也会从中读retry_after。raise FatalError(...)——永不重试,不管max_attempts设了多少。with_retry(ask, max_attempts=5)——用不同策略复用同一个任务。
重试退避不会占着 worker:流水线挂起,worker 去干别的,所以 concurrency 始终名副其实。退避期间结束的运行会把挂着的流水线记成 interrupted,检查点完好,resume 能接着跑。
第 9 步 —— 恢复:什么会重跑,什么不会¶
一次运行能恢复,是因为存储里已经有了剩下的活需要的所有东西。
# tutorial/step_09_resume.py
"""Step 9 - resume: a failed pipeline continues at the first task that produced no artifact."""
from pyattacker import RetryableError, Retrying, Runner, pipeline, task
STATE = {"judge_works": False}
SENT = {"ask": 0, "judge": 0}
@task("prepare")
def prepare(seed: dict) -> dict:
return {"qid": seed["qid"], "prompt": f"explain {seed['qid']}"}
@task("ask", retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
def ask(row: dict) -> dict:
SENT["ask"] += 1 # <- in real life this is the request you do not want to pay for twice
return {**row, "answer": "42"}
@task("judge", retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
def judge(row: dict) -> dict:
SENT["judge"] += 1
if not STATE["judge_works"]:
raise RetryableError("the judge model is down", error_class="upstream")
return {**row, "score": 1}
template = pipeline("eval", prepare | ask | judge)
rows = [{"qid": f"q{i}"} for i in range(3)]
specs = list(template.map(rows))
with Runner(store="runs/step09.db", concurrency=2) as runner:
first = runner.run(iter(specs))
print("round 1:", first.stats["pipelines"]["by_state"], "| requests sent:", SENT,
"| attempts recorded:", len(runner.store.attempts()))
print(" failed at:", sorted({e["failed_task"] for e in runner.store.errors()}))
STATE["judge_works"] = True
second = runner.run(iter(template.map(rows)), resume=True)
print("round 2:", second.stats["pipelines"]["by_state"], "| requests sent:", SENT,
"| skipped:", second.skipped)
print(" -> ask was not re-sent; only the failed task ran again")
row = next(iter(runner.store.export_rows()))
print(f"\n checkpoint: {row['n_tasks_done']}/{row['n_tasks_total']} tasks done, state={row['state']}")
for attempt in runner.store.attempts(pipeline_id=specs[0].pipeline_id):
print(f" {attempt.task_name:<8} attempt {attempt.attempt_no} {attempt.outcome:<9} "
f"run={attempt.run_id[4:]}")
seed_artifact = runner.store.get_artifact(specs[0].pipeline_id, -1)
print(" the seed is a stored artifact too: seq=-1 ->", seed_artifact.type_name,
seed_artifact.digest[:10])
round 1: {'failed': 3} | requests sent: {'ask': 3, 'judge': 6} | attempts recorded: 12
failed at: ['judge']
round 2: {'succeeded': 3} | requests sent: {'ask': 3, 'judge': 9} | skipped: 0
-> ask was not re-sent; only the failed task ran again
checkpoint: 3/3 tasks done, state=succeeded
prepare attempt 1 succeeded run=20260915-213904-b464d3
ask attempt 1 succeeded run=20260915-213904-b464d3
judge attempt 1 failed run=20260915-213904-b464d3
judge attempt 2 failed run=20260915-213904-b464d3
judge attempt 1 succeeded run=20260915-213904-548d63
the seed is a stored artifact too: seq=-1 -> dict d6125621c2
规则按这个顺序适用:
- 流水线已经
succeeded→ 直接跳过。传retry_succeeded=True/--retry-succeeded可以照样重跑(它会从种子开始,因为已完成的流水线没有检查点可以接着跑)。要 丢 掉没成功流水线的检查点,用fresh_restart=True/--fresh-restart——同一个开关也会重置已经耗尽的反向遍历预算。只追加的历史(attempts、events、handoffs)保留;反向流水线还会留着它的访问记录和计数器,正向流水线按设计复用它的任务/工件地址。 - 流水线是
failed或interrupted,且n_tasks_done > 0→ 加载n_tasks_done - 1处的工件,从下一个seq继续。这就是第 2 轮里ask什么都没发的原因:judge 是第一个没有工件的任务,所以只有 judge 跑了。 - 流水线是
failed或interrupted,但游标已经到了末尾(n_tasks_done == n_tasks_total)→ 每个任务都写过检查点了,于是流水线被修复成succeeded,不重跑任何东西:最后一个工件会过校验(存在、有载荷、能解码)、标成 final,pipeline.terminal_repaired会带上这行原来的状态、错误和运行。这就是存储故障或最终写入时被杀留出来的形态。工件过不了校验就走规则 5 和 6;修复本身失败——把工件标 final 或写最终状态——那行保持原样(包括最初的失败),pipeline.terminal_repair_failed记这次尝试,下次运行还是报最初的原因。 - 游标 超过 末尾(
n_tasks_done > n_tasks_total)→ 这不是 Runner 能造出来的状态:这行记成CorruptCheckpoint,报pipeline.corrupt_cursor,永远不会升成成功,存储里的游标原样留着当证据。 - 工件行没了,或者载荷没留下来(
journal=summary、null后端)→ 流水线从seq=0重新开始,记pipeline.checkpoint_missing(终止工件载荷没了也走这条,覆盖规则 3 的情况)。 - 工件还在但解不出来(编解码器被删了、dataclass 改了、载荷坏了)→ 同样重新开始,记成
pipeline.checkpoint_unusable,两种原因能区分开(也覆盖规则 3 里终止工件存在但解不出来的情况)。 - 否则流水线是新的,种子工件在第一个任务跑之前就存好了。
三个注意点:
- 第 2 轮
skipped=0是对的:没有一条流水线成功过,三条都在 failed 状态然后被恢复了。skipped只数规则 1,不是"没重复干的活"。 - 恢复时,数据集流只负责 标识 流水线——恢复后任务的输入来自存储。流水线中途中断的运行不需要数据集文件还在,只要还能枚举出同样的种子就行。
- 每次尝试都有自己的
run_id,所以记录能看出哪次运行做了哪部分活。
值得留意的事件:pipeline.resumed、pipeline.skipped、pipeline.restarted、
pipeline.checkpoint_missing、pipeline.checkpoint_unusable、pipeline.deferred_interrupted、
pipeline.terminal_repaired、pipeline.terminal_repair_failed、pipeline.terminal_cleanup_failed、
pipeline.corrupt_cursor。
还有两个运行级事件值得同样对待。runner.internal_error 是框架层面的意外,记在某条流水线上,运行继续。runner.worker_crashed 更严重:某个 worker 死于自己处理逻辑之外的原因(比如存储钩子抛了个非取消的 BaseException),运行就会停,这个 worker 当时持有的流水线记成 failed——如果运行已经在停了,就记成 interrupted——run() 会抛 WorkerCrashed,把原始异常当原因。看到这个事件就说明这次运行没按它自己预期完成:看流水线行,修原因,然后 --resume 重跑。
第 10 步 —— 读取记录¶
框架知道的一切都存在几张表里。其中五张以 pipeline_id 为键,也就是五种可导出的行类型(--rows);runs 和 resources 有 Python 读取器,但没有导出途径。
# tutorial/step_10_records.py
"""Step 10 - reading the record: five tables, five row kinds, three formats."""
from contextlib import closing
from pyattacker import ROW_KINDS, Runner, export_store, iter_rows, open_store, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"i": seed["i"], "value": seed["i"] * 2}
@task("check")
def check(row: dict) -> dict:
if row["i"] % 2: # deliberately fail the odd rows so the error surface has something to show
raise ValueError("odd rows are not supported")
return {"i": row["i"], "ok": True}
STORE = "runs/step10.db"
with Runner(store=STORE, concurrency=2, label="step-10") as runner:
report = runner.run(pipeline("checked", prepare | check).map([{"i": i} for i in range(4)]))
print(report.summary())
print("\nto_dict() gives you the machine-readable version of the same facts:")
print(" ", {k: v for k, v in report.to_dict().items() if k != "pipelines"})
print("\nlive counters (safe to call while a run is in flight):")
live = runner.stats()
print(" ", {k: live[k] for k in ("in_flight_pipelines", "delayed_pipelines", "buffered")})
print(" counters:", dict(live["counters"]))
print("\nerrors():")
for item in runner.store.errors():
print(f" {item['name']}/{item['failed_task']}: {item['error_type']}: {item['error_message']}")
row = next(item for item in runner.store.export_rows() if item["state"] == "failed")
print(f"\none failed pipeline {row['pipeline_id'][:12]}:")
for item in row["tasks"]:
print(f" seq={item['seq']} {item['name']:<8} {item['state']:<9} "
f"attempts={item['attempts_used']} error={item['error_class']}")
for attempt in runner.store.attempts(pipeline_id=row["pipeline_id"]):
print(f" attempt {attempt.attempt_no} of {attempt.task_name}: outcome={attempt.outcome} "
f"decision={attempt.decision.get('reason')} leases={len(attempt.leases)} "
f"duration_ms={attempt.duration_ms}")
print(" events:", [e.kind for e in runner.store.events(pipeline_id=row["pipeline_id"])][:8])
print("\nrow kinds:")
for kind in ROW_KINDS:
print(f" {kind:<10} {sum(1 for _ in iter_rows(runner.store, kind=kind))} rows")
count = export_store(runner.store, "runs/step10_attempts.csv", kind="attempts", fmt="csv")
print(f"\nexported {count} attempt rows as CSV")
# The store is a plain SQLite file: reopen it from anywhere, any process.
with closing(open_store(STORE)) as reopened:
counts = reopened.stats()
print("reopened:", counts["pipelines"]["total"], "pipelines,",
counts["attempts_total"], "attempts,", counts["events_total"], "events")
run run-... status=completed wall=0.00s
pipelines: total=4 failed=2 succeeded=2
attempts: total=8
tasks: check=4 prepare=4
errors (2):
- checked/check: ValueError: odd rows are not supported
to_dict() gives you the machine-readable version of the same facts:
{'run_id': 'run-...', 'status': 'completed', 'duration_ms': 3.973, 'skipped': 0, 'leases_leaked': 0,
'stop_reason': None, 'tasks': {'by_name': {'check': 4, 'prepare': 4}, ...}, 'attempts_total': 8, 'events_total': 13}
live counters (safe to call while a run is in flight):
{'in_flight_pipelines': 0, 'delayed_pipelines': 0, 'buffered': {'pending': 0, 'flushes': 5, ...}}
counters: {'pipelines_admitted': 4, 'pipelines_succeeded': 2, 'pipelines_done': 4, 'pipelines_failed': 2}
errors():
checked/check: ValueError: odd rows are not supported
one failed pipeline f41b0b0541a2:
seq=0 prepare succeeded attempts=1 error=None
seq=1 check failed attempts=1 error=invalid
attempt 1 of prepare: outcome=succeeded decision=ok leases=0 duration_ms=0.004
attempt 1 of check: outcome=failed decision=attempts_exhausted leases=0 duration_ms=0.004
events: ['task.succeeded', 'task.failed', 'pipeline.failed']
row kinds:
pipelines 4 rows
tasks 8 rows
attempts 8 rows
events 13 rows
artifacts 10 rows
exported 8 attempt rows as CSV
reopened: 4 pipelines, 8 attempts, 13 events
| 表 | 每行代表 | 里面有 | 读取器 |
|---|---|---|---|
runs |
一次运行 | label、status、heartbeat、配置快照、版本、主机 | store.get_run(id)、store.stats() |
pipelines |
一条流水线 | state、检查点(n_tasks_done/…)、key、种子/规格摘要、tags、失败信息、恢复链 |
store.pipelines(...)、store.export_rows() |
tasks |
流水线里的一个任务 | 最终状态、已用尝试数、耗时、工件 id、错误类别 | store.tasks(...)、iter_rows(store, kind="tasks") |
attempts |
一次尝试 | 结果、错误类别/类型/回溯、重试决策、已用租约、耗时 | store.attempts(...)、kind="attempts" |
events |
一个事件 | 结构化事件流:每个任务/流水线/资源的每次状态转换 | store.events(...)、kind="events" |
artifacts |
一个工件 | 载荷、摘要、编解码器、类型、blob_ref |
store.artifacts(pid)、kind="artifacts" |
report.summary()是给人看的,report.to_dict()是给仪表盘用的,runner.stats()用于实时视图(运行中途也安全),store.errors()用于失败列表。attempts是回答"为什么这花了 40 秒"的表:每次尝试的耗时、它的租约日志,还有它的决策(retry、reason、delay_s、error_class)。- 导出形态:
--rows pipelines|tasks|attempts|events|artifacts×--format jsonl|json|csv。CSV 会把嵌套值压成紧凑 JSON,电子表格里打开干干净净。 - 只追加的表(attempts、events)按批写;状态(artifacts、checkpoints)同步写。
SIGKILL最多丢最后一批历史,绝不会丢检查点。--no-write-behind会立刻提交所有内容。
第 11 步 —— 声明式路径与 CLI¶
组装和资源可以放 YAML 里,逻辑还是写在 Python 里。声明式层只做三件事:挑任务、串成链、配资源池。
这是第一个需要标准库之外东西的步骤,也是额外依赖(extra)登场的地方:
uv add "pyattacker[yaml]"(或 pip install "pyattacker[yaml]")。只有 YAML 解析器 是可选的——这一层本身,还有用 .json 或 .toml 写的同一份配置,没有它照样工作。解析器按文件后缀选,所以没装这个额外依赖的机器上,.yaml 文件得到的是一个点明要装 extra 的配置错误,不是一段 import traceback。
# tutorial/step_11_declarative.py
"""Step 11 - the declarative path: composition and resources in YAML, logic still in Python."""
import json
from pathlib import Path
from pyattacker import Runner, load_spec
CONFIG = """
run: { store: runs/step11.db, concurrency: 4, label: declarative }
pools:
apis:
kind: llm
algorithm: backoff # back off instead of failing when every slot is busy
capacity: 2 # pool-level default, overridable per resource
degrade_after: 3 # 3 consecutive failures -> circuit-break for cooldown_s
cooldown_s: 10
resources:
- { id: api-1, options: { model: gpt-4o, base_url: "https://api-a.example/v1",
api_key: "${OPENAI_KEY:-sk-demo}" } }
- { id: api-2, options: { model: gpt-4o } }
pipeline:
name: qa_eval
tags: { bench: tutorial }
tasks:
- use: pyattacker.tasks:echo # replace with "your.pkg.tasks:fetch"
- use: pyattacker.tasks:simulate_llm # replace with "your.pkg.tasks:ask_model"
resource: apis
timeout_s: 30
kwargs: { latency_ms: 5, fail_rate: 0.0, tokens: 64, model: gpt-4o }
retry: { max_attempts: 3, base: 0.01, cap: 0.05, "on": [RetryableError, TimeoutError] }
source: { kind: range, n: 8 }
"""
Path("qa.yaml").write_text(CONFIG, encoding="utf-8")
spec = load_spec("qa.yaml") # <- builds the pools + the pipeline template, imports nothing of yours yet
print(json.dumps(spec.describe(), indent=2))
print("unresolved ${ENV} references:", spec.unresolved_env or "none")
with Runner(pools=spec.pools, **spec.run) as runner:
report = runner.run(spec.pipelines())
print(report.summary())
{
"config": "qa.yaml",
"pipeline": {"name": "qa_eval", "tasks": ["mock.echo", "mock.llm"],
"tags": {"bench": "tutorial"}, "spec_digest": "ad271b5b5b00fcb2..."},
"pools": {"apis": {"kind": "llm", "resources": 2, "capacity": 4, "algorithm": "backoff"}},
"run": {"store": "runs/step11.db", "concurrency": 4, "label": "declarative"},
"source": {"kind": "range", "n": 8},
"unresolved_env": []
}
unresolved ${ENV} references: none
run run-... status=completed wall=0.01s
pipelines: total=8 succeeded=8
tasks: mock.echo=8 mock.llm=8
同一份配置从命令行用:
uv run pyattacker validate -c qa.yaml # parse + summarise, run nothing (exit 2 on a config error)
uv run pyattacker run -c qa.yaml --progress # run; --limit N for a slice
uv run pyattacker resume -c qa.yaml # same as run --resume
uv run pyattacker report runs/step11.db --errors 20
uv run pyattacker watch runs/step11.db # live view from a second process
uv run pyattacker export runs/step11.db out.jsonl --rows attempts --format csv
uv run pyattacker serve runs/step11.db # read-only HTTP debug endpoint (Step 14)
uv run pyattacker plugins # entry points, and which ones failed to load
配置段:
| 配置段 | 键 |
|---|---|
run |
store、concurrency、journal(full 留载荷,summary 只留摘要)、label、strict_leases、stop_after_failures、stop_after_s、retry_succeeded、fresh_restart、heartbeat_s、grace_s、stale_after_s、notes |
pools.<name> |
kind、algorithm、capacity(资源的默认值)、degrade_after、dead_after、cooldown_s、deadlock_warn_s、resources: [{id, kind, capacity, options, tags}] |
pipeline |
name、tags、include_code,以及 tasks: [{use, name, resource, algorithm, timeout_s, retry, args, kwargs}] |
source |
kind: range(n)或 kind: jsonl(path、limit),外加 repeats、key_field |
几个省时间的细节:
use:按形态解析:名字里有冒号的就是module:attribute,直接导入;没冒号的先在内置项里找,再在已安装的插件里找——插件盖不住echo。pyattacker.tasks:simulate_llm和your_pkg.tasks:ask_model没插件也能用。工厂(返回TaskSpec的可调用对象)会用args/kwargs调用。${VAR}和${VAR:-default}会在每个字符串值里展开(键不受影响)。没解析到的名字由validate和describe()["unresolved_env"]报告;--strict-env会把它们当错误。- YAML 1.1 坑: 裸写的
on:会被解析成布尔值true。写成"on": [RetryableError, TimeoutError]——加载器会发现这个错误并提示你。 - 退出码:
0全成功,1部分失败,2配置错,130被中断。
每条命令每个 flag 都在 docs/cli.md 里。
第 12 步 —— 分片与合并¶
横向扩展就是多进程,各有各的存储,事后再合并。流水线分到哪个片由它的内容寻址 key 决定,所以同一份数据集永远按同样方式切。
# tutorial/step_12_shards.py
"""Step 12 - scale out: deterministic shards with their own stores, merged into one answer."""
from pyattacker import (
Runner,
merge_reports,
pipeline,
shard_index,
shard_specs,
shard_store_path,
task,
)
@task("ask")
def ask(seed: dict) -> dict:
return {"i": seed["i"], "answer": seed["i"] * 3}
template = pipeline("eval", ask, tags={"example": "step-12"})
specs = list(template.map([{"i": i} for i in range(12)]))
SHARDS = 3
BASE = "runs/step12.db"
paths = []
for index in range(SHARDS):
mine = list(shard_specs(specs, index, SHARDS)) # blake2b(pipeline_key) % SHARDS == index
path = shard_store_path(BASE, index, SHARDS) # runs/step12.shard0of3.db ...
with Runner(store=path, concurrency=2, label=f"shard-{index}") as runner:
report = runner.run(iter(mine))
paths.append(path)
print(f"shard {index}: {len(mine):>2} pipelines -> {report.stats['pipelines']['by_state']} -> {path}")
merged = merge_reports(paths)
print("\n" + merged.summary())
print("pipeline rows:", len(merged.rows), "| duplicates dropped:", merged.duplicates,
"| sources:", len(merged.sources))
# Merging is idempotent: a duplicated store is de-duplicated by pipeline_id, best state wins.
again = merge_reports([*paths, paths[0]])
print("with one store counted twice:", len(again.rows), "rows,", again.duplicates, "duplicates dropped")
print(" attempts:", merged.stats()["attempts_total"], "->", again.stats()["attempts_total"],
"| source_events:", merged.stats()["source_events_total"], "->",
again.stats()["source_events_total"], "(raw, so it follows the sources)")
merged.export("runs/step12-all.jsonl")
with open("runs/step12-all.jsonl", encoding="utf-8") as handle:
print("merged export rows:", sum(1 for _ in handle))
# Sharding is content-addressed, not round-robin: re-running the same seeds replays the same split.
print("shard of the first pipeline:", shard_index(specs[0].pipeline_id, SHARDS), "of", SHARDS)
shard 0: 6 pipelines -> {'succeeded': 6} -> runs/step12.shard0of3.db
shard 1: 5 pipelines -> {'succeeded': 5} -> runs/step12.shard1of3.db
shard 2: 1 pipelines -> {'succeeded': 1} -> runs/step12.shard2of3.db
merged 12 pipelines from 3 store(s)
succeeded=12 attempts=12 source_events=27
tasks: ask=12
pipeline rows: 12 | duplicates dropped: 0 | sources: 3
with one store counted twice: 12 rows, 6 duplicates dropped
attempts: 12 -> 12 | source_events: 27 -> 40 (raw, so it follows the sources)
merged export rows: 12
shard of the first pipeline: 1 of 3
# N children, one per shard, then a merged report (results land in runs/qa.shard0of4.db …)
uv run pyattacker run -c examples/qa_eval.yaml --shards 4 --jobs 4 --store runs/qa.db
# or drive each shard yourself — cluster, job scheduler, four terminals
uv run pyattacker run -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
uv run pyattacker resume -c examples/qa_eval.yaml --shard 0/4 --store runs/qa.shard0.db
# one coherent answer out of N files
uv run pyattacker report runs/qa.shard*of4.db
uv run pyattacker export runs/qa.shard*of4.db runs/all.jsonl
shard_index(key, N)用blake2b而不是 Python 的hash()(后者按进程加盐)对 key 哈希,所以切分跨多次运行、跨机器都稳定。--shard 2/4 --resume会把每条流水线放回原来的位置。各分片大小只是大致相等——12 条切成 6/5/1 不是 bug。- 合并按
pipeline_id去重,保留最好的状态(succeeded > failed > interrupted,平了取最晚完成的),并且从存活下来的行重算工作量计数——attempts_total、handoffs_total。部分重叠的重跑,或者同一个存储算了两次,结果还是一个答案。事件日志是刻意的例外:事件不挂在流水线行上,所以source_events_total是你传入那些源库的原始总数,名字就是这么起的,不假装成别的口径。 merge_reports([...])返回一个MergedReport,带.summary()、.stats()、.errors()和.export(path, fmt=..., kind=...)。
第 13 步 —— 压轴:一个小型模型评估¶
这就是一次真实评估的样子:prepare → 两轮模型调用 → 三个评委 → reduce,配两个模型资源池、重试、一次接口方故障,还有一次我们真的去度量代价的恢复。
# tutorial/step_13_capstone.py
"""Step 13 - capstone: a small model evaluation, with the price of its checkpoint granularity measured."""
import asyncio
from pyattacker import (
Pool,
Resource,
RetryableError,
Retrying,
Runner,
fanout,
pipeline,
task,
)
class RequestLog:
"""Counts what actually left the process - the number this whole exercise is about."""
def __init__(self) -> None:
self.sent: list[str] = []
def note(self, model: str) -> None:
self.sent.append(model)
def counts(self, since: int = 0) -> dict[str, int]:
out: dict[str, int] = {}
for model in self.sent[since:]:
out[model] = out.get(model, 0) + 1
return out
LOG = RequestLog()
DOWN: set[str] = {"judge-b"} # the provider having a bad day; cleared before the resume round
class Backend:
"""Your client. The framework never opens a socket; this class is where the network lives."""
def __init__(self, log: RequestLog, model: str) -> None:
self.log = log
self.model = model
async def chat(self, prompt: str) -> str:
self.log.note(self.model)
await asyncio.sleep(0.002) # <- the HTTP request
if self.model in DOWN:
raise RetryableError(f"{self.model} is unavailable", error_class="upstream")
return f"[{self.model}] {prompt[:40]}"
def backend_factory(resource: Resource) -> Backend:
return Backend(LOG, resource.options["model"])
@task("prepare")
def prepare(seed: dict) -> dict:
return {"qid": seed["qid"], "question": seed["question"]}
@task("ask", resource="models", timeout_s=10)
async def ask(row: dict, ctx) -> dict:
"""One task = one step of the pipeline, but it may make as many requests as it needs."""
messages = [f"Q: {row['question']}"]
async with ctx.acquire(model="eval-model") as lease:
for _ in range(2): # a two-turn conversation, inside a single checkpoint
messages.append(await lease.client.chat(" | ".join(messages)))
lease.report(ok=True, usage={"tokens": 64})
return {**row, "transcript": messages}
def judge(model: str):
@task(f"judge.{model}", resource="judges", algorithm="least_busy",
retry=Retrying(max_attempts=2, base=0.005, cap=0.02))
async def judge_with(row: dict, ctx) -> dict:
async with ctx.acquire(model=model) as lease:
verdict = await lease.client.chat(row["transcript"][-1])
lease.report(ok=True, usage={"tokens": 8})
return {**row, "judge": model, "verdict": verdict}
return judge_with
judges = fanout(judge("judge-a"), judge("judge-b"), judge("judge-c"), name="judges")
@task("reduce")
def reduce_scores(row: dict) -> dict:
"""Aggregation across pipelines stays yours to write - the framework stores it, it does not judge it."""
branches = list(row.values())
return {
"qid": branches[0]["qid"],
"verdicts": {b["judge"]: b["verdict"] for b in branches},
"n_models": len(branches),
}
def make_pools() -> list[Pool]:
return [
Pool("models", [Resource.create("llm", id="m-1", capacity=4,
options={"model": "eval-model"}, factory=backend_factory)]),
Pool("judges", [Resource.create("llm", id=f"j-{name}", capacity=1,
options={"model": name}, factory=backend_factory)
for name in ("judge-a", "judge-b", "judge-c")]),
]
template = pipeline("eval", prepare | ask | judges | reduce_scores, tags={"bench": "tutorial"})
rows = [{"qid": "q1", "question": "2 + 2 = ?"}, {"qid": "q2", "question": "3 + 4 = ?"}]
with Runner(store="runs/step13.db", pools=make_pools(), concurrency=4) as runner:
first = runner.run(template.map(rows))
print("round 1:", first.stats["pipelines"]["by_state"],
"| requests:", LOG.counts(),
"| failed at:", sorted({e["failed_task"] for e in runner.store.errors()}))
DOWN.clear()
mark = len(LOG.sent)
second = runner.run(template.map(rows), resume=True)
print("round 2:", second.stats["pipelines"]["by_state"],
"| requests:", LOG.counts(mark), "| skipped:", second.skipped)
print("total requests:", len(LOG.sent))
row = next(iter(runner.store.export_rows()))
print("\nfinal artifact:", row["artifacts"][-1]["payload"])
# The resume was honest but not free: judge-a and judge-c were re-sent even though their verdicts
# were already on disk. Putting the three judges in three *tasks* (C1 | C2 | C3) makes the
# checkpoint finer: the same failure would resume at C2, and re-send nothing that succeeded.
# examples/llm_eval/ measures both shapes side by side.
print("\nthe grouped shape re-sent verdicts that were already persisted:",
sum(LOG.counts(mark).values()) - 2, "of", len(LOG.sent) - mark, "requests in round 2")
round 1: {'failed': 2} | requests: {'eval-model': 4, 'judge-a': 4, 'judge-b': 4, 'judge-c': 4} | failed at: ['judges']
round 2: {'succeeded': 2} | requests: {'judge-a': 2, 'judge-b': 2, 'judge-c': 2} | skipped: 0
total requests: 22
final artifact: {'n_models': 3, 'qid': 'q1',
'verdicts': {'judge-a': '...', 'judge-b': '...', 'judge-c': '...'}}
the grouped shape re-sent verdicts that were already persisted: 4 of 6 requests in round 2
每部分是干嘛的:
ask在同一个任务里发两次请求。 多轮对话、工具循环,或者"重试解析直到过校验"的循环,都属于同一个任务内部。一个步骤,一个工件。fanout(...)把分支留在任务内部。 三个评委在同一输入上并发跑,返回{task_name: value}。子任务共享父任务的上下文,所以它们的租约和事件落在同一条记录里。Runner 只看到组 spec,所以只有所有子任务都一致时,才从子任务里提resource、algorithm、timeout_s——这就是三个评委都声明同一个资源池和算法的原因。- 按角色分资源池。
models一个资源容量 4;judges每个模型各一个资源容量 1,三个分支永远不会互相排队。 - 故障变成了数据。
judge-b的RetryableError(error_class="upstream")会被分类,按组策略重试,记成一次失败,标清楚是哪个任务。 - 恢复会暴露它的代价。 第 2 轮把整个 judges 组重跑了,所以
judge-a和judge-c被再发了一遍——尽管它们的判定就在磁盘上——六次请求里四次是重复的。
最后一点是步骤要分支时你必须做的选择:
如果一次请求很贵或很慢,就给它单独一个任务。如果一个步骤是在一堆廉价调用上 fanout,一个任务加一个检查点才更划算。
把三个评委当三个任务(C1 | C2 | C3)检查点更细:同样的失败会在 C2 恢复,不重发任何已经成功的内容,代价是多两个流水线步骤。
examples/llm_eval/ 在同一批任务上实现了两种形态并做了对比——一个评审端点宕机时,分组形态重发了 2 个已成功的请求,拆分形态重发了 0 个。
第 14 步 —— 自定义类型、blob、插件¶
三个扩展点几乎覆盖一切:你的类型用编解码器、载荷放哪用工件后端、代码能从配置文件寻址用入口点插件。
# tutorial/step_14_extending.py
"""Step 14 - your own types and your own blobs: a codec, a size comparison, and an artifact backend."""
import json
import struct
from pathlib import Path
from pyattacker import CodecRegistry, FileBackend, Runner, list_plugins, pipeline, task
class Embedding:
"""A type the built-in JSON codec would store as a fat list of floats."""
def __init__(self, values: list[float]) -> None:
self.values = values
def __len__(self) -> int:
return len(self.values)
def __repr__(self) -> str:
return f"Embedding({len(self.values)} floats)"
class EmbeddingCodec:
"""Four methods: a name, a claim on a type, and an encode/decode pair."""
name = "f32"
def can_encode(self, obj: object) -> bool:
return isinstance(obj, Embedding)
def dumps(self, obj: Embedding) -> bytes:
return struct.pack(f"<{len(obj.values)}f", *obj.values)
def loads(self, data: bytes) -> Embedding:
return Embedding(list(struct.unpack(f"<{len(data) // 4}f", data)))
# A private registry: the same object must be given to the pipeline (for seeds) and to the Runner.
registry = CodecRegistry()
registry.register(EmbeddingCodec(), for_types=(Embedding,))
@task("embed")
def embed(seed: dict) -> Embedding:
return Embedding([float(i) for i in range(seed["dim"])])
@task("norm")
def norm(vec: Embedding) -> dict:
return {"dim": len(vec), "norm": round(sum(v * v for v in vec.values) ** 0.5, 3)}
template = pipeline("embedding", embed | norm, registry=registry)
with Runner(store="runs/step14.db", pools=[], concurrency=2, registry=registry,
# Spill payloads of any size to content-addressed files instead of the database.
artifact_backend=FileBackend(root="runs/blobs", min_bytes=0)) as runner:
report = runner.run(template.map([{"dim": 384}]))
print(report.summary())
row = next(iter(runner.store.export_rows()))
for item in row["artifacts"]:
shown = str(item["payload"])[:36]
print(f" seq={item['seq']:>2} {item['task']:<10} type={item['type']:<10} codec={item['codec']:<5} "
f"payload={shown + '...' if len(shown) == 36 else shown}")
pipeline_id = row["pipeline_id"]
vector = runner.store.get_artifact(pipeline_id, 0)
print("built-in json would need", len(json.dumps([float(i) for i in range(384)])), "bytes;",
"the f32 codec used", vector.size, "bytes")
print("the store kept only a reference:", f"blobs/{vector.digest[:2]}/{vector.digest[2:]}",
"- the payload is not in the database")
print("restored from the blob:", type(registry.load(vector.encoded())).__name__,
registry.load(vector.encoded()))
files = [p for p in sorted(Path("runs/blobs").rglob("*")) if p.is_file()]
print("blobs on disk:", len(files), "files,", sorted(p.stat().st_size for p in files), "bytes")
print("installed plugins:", len(list_plugins()),
"(entry points; see examples/plugin_package for a complete one)")
run run-... status=completed wall=0.01s
pipelines: total=1 succeeded=1
...
seq=-1 __seed__ type=dict codec=json payload={'dim': 384}
seq= 0 embed type=Embedding codec=f32 payload=AAAAAAAAgD8AAABAAABAQAAAgEAAAKBAAADA...
seq= 1 norm type=dict codec=json payload={'dim': 384, 'norm': 4335.978}
built-in json would need 2578 bytes; the f32 codec used 1536 bytes
the store kept only a reference: blobs/15/0b9b3a521bb02c0f88eab2716c9d61 - the payload is not in the database
restored from the blob: Embedding Embedding(384 floats)
blobs on disk: 3 files, [11, 27, 1536] bytes
- 编解码器要四个成员:
name、can_encode(obj)、dumps(obj) -> bytes、loads(bytes) -> obj。用registry.register(codec, for_types=(MyType,))注册,把同一个注册表传给用于种子的pipeline(..., registry=...)和用于工件的Runner(registry=...)。认领某类载荷的编解码器压过内置的 JSON 兜底。 - 工件后端决定载荷字节放哪:
None/"inline"留在数据库里,"null"丢掉字节但留摘要,"file:///data/blobs"(或{"kind": "file", "root": ..., "min_bytes": 262144})溢到内容寻址的文件里,读取时再水合回来。上面的min_bytes=0什么都溢出去,所以工件行显示的是引用而不是字节。 - 插件是
importlib.metadata入口点,分四组:pyattacker.tasks、pyattacker.algorithms、pyattacker.codecs和pyattacker.stores(按 URI scheme 索引,store = "s3://bucket/runs.db"就能用)。内置项先解析,导入时报错的插件记下来不致命——pyattacker plugins两者都列。完整示例包在examples/plugin_package/。
监控正在跑的运行。 pyattacker watch runs/qa.db 让你从第二个进程看终端视图,pyattacker serve runs/qa.db 提供 HTTP 仪表盘,在 /stats、/metrics、/events、/pipelines、/resources 和 /errors 提供 JSON。两个都开只读连接,能和正在跑的任务并排用。HTTP 端点没认证,会把你的载荷给出来——只绑回环地址。
需要实时实验准确率时,运行 examples/live_metrics.py。示例的
完成回调解码最终产物、按 pipeline ID 去重,再通过 runner.report_metric() 汇报当前
准确率;面板会随着汇报更新。
* 监控:pyattacker serve runs/qa.db 是零依赖的只读 HTTP 视图(/、
/stats、/metrics、/events、/pipelines、/resources、/errors),每个请求开个新连接,和正在跑的任务并行无碍。绑回环地址、没认证——当调试视图用,别当仪表盘用。
第 15 步 —— 跳过站点(交接)¶
交接是正式的控制流特性,通过 control 显式声明后启用。 这步之前的东西都不依赖它,没声明它的流水线和这个特性不存在时完全一样。当你的某个步骤判断链的其余部分不用跑了,再读这步。
场景是这样:judge 能看出某个答案已经够好了,或者 metrics 这一步对这一行没必要。以前的选择是要么照常跑完剩余任务,要么用 fanout 把所有东西折进一个任务(丢了逐步记录),要么抛错——抛错会把流水线记成 failed,这是在撒谎。交接(handoff)说清楚实际发生了什么:这一行跳过了站点 3–5,从站点 6 继续,或者就在这里结束。
# tutorial/step_15_handoff.py
"""Step 15 - a task hands off: skip stations, or finish the pipeline, and nothing lies about it."""
import json
from contextlib import closing
from pyattacker import Handoff, Runner, open_store, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"q": seed["q"], "confidence": seed["confidence"]}
@task("ask")
async def ask(row: dict, ctx) -> dict:
# <- your HTTP call: the model answers, and reports how sure it is
await ctx.clock.sleep(0.001)
return {**row, "answer": f"answer-for:{row['q']}"}
@task("judge")
def judge(row: dict) -> Handoff | dict:
"""Three outcomes: finish here, skip metrics, or carry on down the chain."""
if row["confidence"] >= 0.9:
# already good enough: end the pipeline with this as its final artifact
return Handoff.end({"q": row["q"], "answer": row["answer"], "verdict": "confident"},
reason="already good enough")
if row["confidence"] >= 0.5:
# not worth the metrics call, but the report is still wanted: continue at "report"
return Handoff.to("report", {"q": row["q"], "answer": row["answer"], "verdict": "ok"},
reason="metrics not needed")
return {**row, "verdict": "needs-metrics"}
@task("metrics")
def metrics(row: dict) -> dict:
return {**row, "score": round(row["confidence"] * 10, 1)}
@task("report")
def report(row: dict) -> dict:
return {**row, "reported": True}
# The edges are declared, not derived: "report" is what judge may jump to, and END finishes the pipeline.
template = pipeline(
"qa",
prepare | ask | judge | metrics | report,
control={"edges": {"judge": ["report", "end"]}},
)
rows = [
{"q": "capital of France", "confidence": 0.95}, # judge ends the pipeline (metrics and report skipped)
{"q": "17 * 23", "confidence": 0.6}, # judge skips metrics, continues at report
{"q": "prove sqrt(2) is irrational", "confidence": 0.2}, # judge hands nothing off: the full chain runs
]
with Runner(store="runs/handoff.db", concurrency=4) as runner:
report_obj = runner.run(template.map(rows))
print(report_obj.summary())
store = runner.store
for record in store.pipelines():
ran = [t.name for t in store.tasks(record.pipeline_id)]
skipped = [t.name for t in template.tasks if t.name not in ran]
print(f"\n{record.pipeline_id[:8]} state={record.state} ran={ran} skipped={skipped}")
for hop in store.handoffs(pipeline_id=record.pipeline_id):
where = "END" if hop.to_seq is None else hop.to_task
print(f" jumped: {hop.from_task} -> {where} ({hop.reason}) entry={hop.entry_artifact_id}")
# The record is a plain store: reopen it whenever, and read the same ledger back.
with closing(open_store("runs/handoff.db")) as reopened:
print("\nhandoffs recorded:", reopened.stats()["handoffs_total"])
row = next(iter(reopened.export_rows()))
print("first exported pipeline's ledger:", json.dumps(row["handoffs"], ensure_ascii=False))
三行数据走了三条不同的路,记录如实说了,而且它们都没失败:
ec5fc1e5 state=succeeded ran=['prepare', 'ask', 'judge'] skipped=['metrics', 'report']
jumped: judge -> END (already good enough) entry=ec5fc1e5dff6a260512e579a276d11e0:5
cdce72ca state=succeeded ran=['prepare', 'ask', 'judge', 'report'] skipped=['metrics']
jumped: judge -> report (metrics not needed) entry=cdce72cae311e870d87d85e9dbcaa280:5
260cf635 state=succeeded ran=['prepare', 'ask', 'judge', 'metrics', 'report'] skipped=[]
用之前有几点值得知道:
- 它是返回值,不是异常。 重试策略永远看不到它,任务侧的
except Exception:吞不掉它,async with ctx.acquire(...)退出时已经还了租约。被取消或超时的尝试走不到返回那行,所以不会有半途转移的状态。 - 边是声明出来的,写错就很醒目。 从没有
control块的流水线返回Handoff,或者沿着一条不是从这个任务声明的边返回,都会触发FatalError——永不重试,也不会静默跳转。目标必须严格晚于源(edges只允许向前),链里出现两次的名字必须给 seq,最后一个任务发end会被拒——因为它什么都不做。 - 交接就是检查点,所以恢复从目标处继续。 进程在跳转后死了,下次
resume=True会带着记下来的入口状态从目标开始,不会重跑源任务。handoffs行让这事可能:它记了目标和入口工件,入口工件要么是源任务收到的那个(不带值的Handoff.to(target)),要么是存在链之上自己地址处的新载荷(seq >= n_tasks)。 n_tasks_done变成了一个位置。 跳过的槽位没有任务行,所以开了 control 的流水线上,n_tasks_done / n_tasks_total不是完成百分比——要看实际发生了什么,靠store.handoffs()和stats()["handoffs_total"]。report、watch、/pipelines和每种导出都会在旁边显示交接计数。- 别一上来就用它。 交接是关于当前流水线的调度声明:"这一行应该从那边继续"。条件还是写在任务代码里,步骤内分支还是
fanout,遍历数据集还是map。一条几乎全是交接的流水线说明这个问题要的是图引擎,这个框架不是。 - 正式控制流特性。
HandoffAPI、control声明及上述保证都遵循项目的版本管理策略。单独声明的反向操作用访问和有限预算;见第 16 步和 reference → 反向遍历。两个内置存储都能提交交接;不能提交的自定义存储一开始就被拒,抛ConfigError,不会写一次崩溃后活不下来的跳转。
整条流水线重启(显式 fresh_restart=True、在 retry_succeeded=True 下已经成功的流水线,或入口载荷丢失)会把之前的交接、尝试和事件留作历史,但在从 seq 0 重新开始之前,连同持久账本水位一起原子地清掉当前的任务/链工件状态。之后失败没法恢复旧的跳转,也找不回旧的 END 结果。后续的目标恢复保留当前水位,反复中断时还在用生效中的交接。完成时留下一个最终工件。实现后端时见
存储恢复契约。
第 16 步 —— 用回退和全部重试重新生成¶
与正向跳转一样,回退和全量重试也是通过 control 声明启用的正式特性。 它改了链的遍历方式:校验器可以把工作送回更早的站点,而不是让这一行失败,或者在一个任务内部循环。当某个步骤判断更早的步骤该带着不同状态重新跑,再读这步。
场景是这样:validate 拒了一个结构化答案,修复方式是带着同样的提示词再加错误反馈重新生成一次。把这个循环折进一个任务,会把生成和校验压成一条记录——"这一行需要重新生成几次"恰恰会在它本身就是度量指标的地方变得不可见。
回退(rewind)会把两次生成保留成彼此独立的访问,各自有自己的任务、尝试和工件行。
# tutorial/step_16_backward.py
"""Step 16 - a validator rewinds to the generator; state is what the author chose."""
from pyattacker import Handoff, Runner, pipeline, task
@task("prepare")
def prepare(seed: dict) -> dict:
return {"prompt": seed["prompt"], "temperature": 0.2}
@task("generate")
def generate(state: dict, ctx) -> dict:
# <- your model call: a revisit is a genuinely new sample
return {**state, "answer": f"sample-{ctx.visit}", "valid": ctx.visit >= 1}
@task("validate")
def validate(row: dict, ctx) -> Handoff | dict:
if not row["valid"]:
# Explicit state: the author decides what the generator gets, the framework guesses nothing.
return Handoff.rewind(
"generate",
{"prompt": row["prompt"], "temperature": 0.7, "feedback": "answer did not parse"},
reason="invalid structured output",
)
return {**row, "validated": True}
@task("report")
def report(row: dict, ctx) -> dict:
# ctx.visit counts entries into *this* station, and report ran once — so it is 0 here. How many
# regenerations the row needed is the generate station's counter, printed from the store below.
return {"answer": row["answer"], "temperature": row["temperature"]}
# `rewind` is declared from validate to the strictly earlier generate; max_handoffs is required and is
# the loop budget: transfer N+1 fails before it can invalidate anything.
template = pipeline(
"regenerate",
prepare | generate | validate | report,
control={"rewind": {"validate": ["generate"]}, "max_handoffs": 3},
)
with Runner(store="runs/backward.db", max_handoffs=10) as runner: # 10 is a ceiling, not a floor
report_obj = runner.run(template.map([{"prompt": "Return JSON with one field"}]))
print(report_obj.summary())
store = runner.store
for record in store.pipelines():
print(f"\n{record.pipeline_id[:8]} state={record.state} position={record.n_tasks_done}")
for row in store.tasks(record.pipeline_id):
print(f" seq={row.seq} visit={row.visit} {row.name:9s} {row.state}")
for hop in store.handoffs(pipeline_id=record.pipeline_id):
print(f" {hop.operation}: {hop.from_task} -> {hop.to_task} (visit {hop.from_visit} -> {hop.to_visit})")
state = store.visit_state(record.pipeline_id)
print("\nbudget consumed:", state["handoffs"], " visits per station:", state["counters"])
print("effective outputs:", {s: store.get_artifact(record.pipeline_id, s).visit for s in range(4)})
# Every occurrence is still there by exact id, including the superseded first generation.
print("first generation kept:", store.get_artifact_by_id(f"{record.pipeline_id}:1").payload)
这一行被生成了两次,记录显示两次访问,不是把重试藏起来:
succeeded position=4
seq=0 visit=0 prepare succeeded
seq=1 visit=0 generate succeeded
seq=1 visit=1 generate succeeded
seq=2 visit=0 validate handed_off
seq=2 visit=1 validate succeeded
seq=3 visit=0 report succeeded
rewind: validate -> generate (visit 0 -> 1)
budget consumed: 1 visits per station: {'0': 0, '1': 1, '2': 1, '3': 0}
effective outputs: {0: 0, 1: 1, 2: 1, 3: 0}
用之前有几点值得知道:
- 状态你选;框架不会回滚字典。
Handoff.rewind(target, value)要求显式给值(None也是真实的值),目标必须是已声明的、严格更早的任务。目标之前的结果保持生效;目标及其之后的一切变成历史,以新访问重新跑。 Handoff.retry_all()从最初绑定的种子重新开始——那个种子是绑定时捕获的字节重新解出来的——之后改spec.seed或某个任务的输入都不会变它。重启想以不同状态开始,就用Handoff.rewind(0, chosen_state)。- 循环有界,所以终止不是结构性的。 反向计划必须给
control.max_handoffs,RunConfig.max_handoffs(默认 1000,配置文件里run.max_handoffs)可以调低:生效的限制是两者较小值。END永不消耗一次转移。失败的转移在任何东西失效之前被拒,所以记录还是精确描述已提交的内容。 - 用完预算怎么脱身、怎么脱离
running行,都是显式操作。resume=True保留已消耗的预算继续当前遍历,所以一条因为转移次数用完而失败的流水线需要fresh_restart=True/--fresh-restart:新预算生命周期、从绑定种子重新开始,访问计数器和审计行都保留。还显示running的行——硬杀进程留下的形态——只有resume=True才会认领它;不带它,运行跳过这行,不分叉一条可能还归另一次运行所有的遍历。 - 崩溃丢不了谱系。 入口、访问分配、生效槽位和待处理输入原子地一起提交,所以恢复带着所选载荷继续同一次访问,不重放整条流水线。框架给不了的是外部副作用的恰好一次语义——每次重新生成都该是一次新的外部操作时,把
ctx.visit放进幂等键。 - 访问 0 不变。 从不回退的流水线保留
pipeline_id:seq形式的 id、同样的随机流和同样的spec_digest,所以让流水线换地址的是添加声明这个动作,不是升级包。 - 普通字典还是普通字典。
HistoryArtifact是可选的载荷基类,做快照/恢复的簿记;运行器里没有任何东西会读它来决定下一步去哪。
完整接口——访问/发生实例(occurrence)模型、预算生命周期、存储能力和
HistoryArtifact 编解码器——见 reference → 反向遍历,可选的载荷历史在下一步。
第 17 步 —— 让载荷自带历史¶
载荷历史是控制流的可选配套功能,完全不改调度。 第 15、16 步决定流水线去哪里;这步是回退的可选搭档,用于你送回的那个状态本身需要带具名检查点的场合。
场景:validate 想把 generate 送回那个失败样本之前的状态,而不是作者手搓的字典。手搓那个字典很常见,第 16 步就是这么做的,本身没任何问题。但当载荷本身就是应用的状态机——它有若干阶段,早先阶段值得留着,"从阶段 2 重新生成,同时让阶段 3 留在记录里"才是你要的操作——HistoryArtifact 就是这个可选的载荷基类:它带着应用状态的分离快照,以及对应的版本化编解码器。
# tutorial/step_17_history.py
"""Step 17 - optional payload history: named checkpoints carried inside the payload."""
from pyattacker import CodecRegistry, Handoff, HistoryArtifact, Runner, pipeline, task
class DraftState(HistoryArtifact):
"""Application state with its own snapshots; the runner stores it and never reads it."""
@task("prepare")
def prepare(seed: dict) -> DraftState:
state = DraftState({"prompt": seed["prompt"], "temperature": 0.2})
return state.checkpoint("prepared")
@task("generate")
def generate(state: DraftState, ctx) -> DraftState:
# <- your model call: a revisit is a genuinely new sample, so the label names the visit
draft = state.with_state({**state.state, "answer": f"sample-{ctx.visit}", "valid": ctx.visit >= 1})
return draft.checkpoint(f"sample-{ctx.visit}", metadata={"temperature": draft.state["temperature"]})
@task("validate")
def validate(state: DraftState, ctx) -> Handoff | DraftState:
if not state.state["valid"]:
# Go back to the snapshot taken before this sample, then choose what the generator sees next.
base = state.restore("prepared")
return Handoff.rewind(
"generate",
base.with_state({**base.state, "temperature": 0.7}),
reason="answer did not parse",
)
return state
@task("report")
def report(state: DraftState, ctx) -> dict:
# The decoded payload still carries every snapshot, in order, plus the one that is selected.
return {
"answer": state.state["answer"],
"labels": [row["label"] for row in state.history],
"selected": state.selected,
}
registry = CodecRegistry()
registry.register_type(DraftState) # decoding restores DraftState, not a bare HistoryArtifact
# The same registry goes to the template (for seeds) and to the Runner (for checkpoints) - Step 14's rule.
template = pipeline(
"draft",
prepare | generate | validate | report,
registry=registry,
control={"rewind": {"validate": ["generate"]}, "max_handoffs": 3},
)
with Runner(store=":memory:", registry=registry) as runner:
report_obj = runner.run(template.map([{"prompt": "Return JSON with one field"}]))
print(report_obj.summary())
store = runner.store
record = next(iter(store.pipelines()))
for row in store.tasks(record.pipeline_id):
print(f" seq={row.seq} visit={row.visit} {row.name:9s} {row.state}")
validate_output = runner.registry.load(store.get_artifact(record.pipeline_id, 2).encoded())
print("\nfinal artifact:", runner.registry.load(store.get_artifact(record.pipeline_id, 3).encoded()))
print("snapshots:", [row["label"] for row in validate_output.history])
print("selected:", validate_output.selected)
pruned = validate_output.prune("sample-0") # explicit, and never the selected snapshot
print("after pruning sample-0:", [row["label"] for row in pruned.history])
try:
pruned.prune("prepared")
except ValueError as exc:
print("prune refused:", exc)
遍历过程和第 16 步一样;新的一点是载荷自己记住了它走过的位置:
seq=0 visit=0 prepare succeeded
seq=1 visit=0 generate succeeded
seq=1 visit=1 generate succeeded
seq=2 visit=0 validate handed_off
seq=2 visit=1 validate succeeded
seq=3 visit=0 report succeeded
final artifact: {'answer': 'sample-1', 'labels': ['prepared', 'sample-0', 'sample-1'], 'selected': 'snapshot:0'}
snapshots: ['prepared', 'sample-0', 'sample-1']
selected: snapshot:0
after pruning sample-0: ['prepared', 'sample-1']
prune refused: cannot prune the selected snapshot
动手之前值得知道的事:
- 它是载荷,不是记录。
HistoryArtifact是解码后的应用值;它不是持久化的Artifact行,快照历史也绝不取代框架的执行账本、任务行或访问记录。runner 里没有任何代码会读它来决定下一步去哪——你返回的Handoff.rewind仍然是唯一移动流水线的东西。普通字典还是普通字典:任务进入或完成时不会自动生成快照。 - 快照是分离的,嵌套修改改不了过去。
state、history和snapshot(...)返回的都是深拷贝;checkpoint(label, *, metadata=None)追加一个带稳定 id(snapshot:0、snapshot:1……)和唯一标签的快照,snapshot:前缀留给这些 id。with_state(value)替换当前状态但不追加快照;restore(id_or_label)替换当前状态并且记selected,同时保留整段历史,让后续阶段仍可检视;prune(*selectors)是显式丢快照的方式——选择器指向被选中的快照时它抛ValueError不照做,id 不会重用。 - 持久化是提交的事,不是
checkpoint()的事。 在任务里调checkpoint()不碰存储。runner 在提交任务输出或控制转移时把载荷(连同历史)一起持久化,和其他工件完全一样。那次提交之前崩溃,内存里的快照就丢了,编解码器帮不上忙。 - 编解码器需要那个类。 状态和 metadata 必须可 JSON 序列化,版本化的
history-v1编解码器恢复的不只是快照,还有已注册的子类:把同一个CodecRegistry交给pipeline(..., registry=...)和Runner(..., registry=...)(第 14 步),用register_type注册子类——未注册的子类明确解码失败,不会以HistoryArtifact身份回来。子类继承基类构造函数,应用字段放state里;自定义构造函数和额外属性不在这个接口范围。 - 历史随载荷一起长。 每个快照都完整保留,长期存活的值的体积会随快照数量和大小增长;要有意识地 prune。"把这一行送回去"这种用法,常见形态是每次访问留一个快照,外加你想回到的那些状态——上面的程序就是这么做的。
速查表¶
| 我想…… | 这样做 |
|---|---|
| 在一个数据集上跑一个任务 | Runner(store=..., pools=[...]).run(template.map(rows)) |
| 每行 k 个样本 | template.map(rows, repeats=k) |
| 从我的数据集生成稳定 id | template.map(rows, key_of=lambda r: r["qid"]) |
| 不碰磁盘地测试 | Runner(store=":memory:") |
| 查看发生了什么 | report.summary()、report.to_dict()、store.errors() |
| 查看为什么慢 | store.attempts(pipeline_id=...) → duration_ms、decision、leases |
| 崩溃后恢复 | runner.run(specs, resume=True) 或 pyattacker resume -c cfg.yaml |
| 重跑我不信任的结果 | retry_succeeded=True / --retry-succeeded |
| 从种子重新开始一条流水线,保留审计行 | fresh_restart=True / --fresh-restart |
| 限制影响范围 | stop_after_failures=N、stop_after_s=T、--limit N |
| 限制每个端点的并发 | Resource.create(..., capacity=N) |
| 快速失败不排队 | algorithm="immediate" + Retrying(retry_unknown=True) |
| 扛过 429 | raise RetryableError(..., error_class="rate_limit", retry_after=...) |
| 把大载荷存到数据库之外 | --artifact-backend file:///data/blobs |
| 用四个进程 | --shards 4 --jobs 4,然后对分片文件跑 report/export |
| 在步骤内分支 | fanout(task_a, task_b) |
| 有记录地向前跳 / 提前结束 | 在声明了 control={"edges": {...}} 的流水线上 return Handoff.to("report", v) / Handoff.end(v) |
| 把一个站点送回更早的站点 | 在声明了 control={"rewind": {...}, "max_handoffs": N} 的流水线上 return Handoff.rewind("generate", chosen_state) |
| 让整条流水线从自己的种子重新开始 | 在声明了 control={"retry_all": [...], "max_handoffs": N} 的流水线上 return Handoff.retry_all() |
| 在载荷内部保留具名状态检查点 | class S(HistoryArtifact),然后 s.checkpoint("label") / .restore(...) / .prune(...),并在两个注册表里登记 |
| 让我的代码能从 YAML 用 | 不用插件:use: my_pkg.tasks:my_task;在 pyattacker.tasks 有入口点:use: my_task |
所有类和函数,含签名和参数表:docs/reference.md。
疑难排查¶
"我的任务接收三个参数。" 任务是一元的:(value) 或 (value, ctx)。额外状态放进工厂闭包(def make_task(model): @task(...) async def t(value, ctx): ...; return t)——第 6 步用的就是这个模式。
"恢复重跑了整条流水线。" 存储几乎肯定是用 journal: summary 写的,留了摘要没留载荷,检查点解不出来。查 pipeline.checkpoint_missing 事件。用 journal: full(默认值)。
"什么都没跑,也没有任何行。" 每条流水线都因为已经成功被跳过了——看 report.skipped。这是预期行为,第二次完全相同的运行也这样。
"一条流水线以 unknown 失败了。" 这个异常没法归类(比如 ResourceUnavailable,或者裸的 Exception)。要么映射它——raise RetryableError(...)——要么在确实想重试任何东西时设 retry_unknown=True。
"它挂住了,没输出。" 要么是选择器匹配不到任何资源而算法是 wait;要么是一个任务占了某个资源池的租约,又向同一个资源池申请另一个(查 acquire.suspected_deadlock 事件);要么是你自己代码里某个 await 永不返回。任务上的 timeout_s= 和 acquire 回合上的 timeout= 都会变成错误。
"我的资源永远不回来。" 它会回来的——查 leases_leaked 和 lease.leaked 事件:你用了 await ctx.acquire_lease() 没还。用 async with,CI 里开 strict_leases=True 让这种情况醒目地失败。
"存储文件被占用 / 一个进程不够用。" SQLite 只允许一个写入者。分片到几个各自有存储的进程(第 12 步),别让多次运行指向同一个文件。
"我的工件字节去哪了?" 设了 journal: summary 或 null 后端,就只留摘要。否则查 blob_ref:载荷可能在文件后端里,读取时透明水合。
"一次运行在重试退避里花了 30 秒,我丢了并发。" 你没丢:这条流水线挂在延迟队列里,worker 去做了别的。runner.stats()["delayed_pipelines"] 显示当前挂着多少条。
"反向流水线在建流水线时就失败,或者跑着跑着预算就耗尽了。" 只要声明了 rewind 或 retry_all,control.max_handoffs 就是必需的,必须是正整数,所以 0、"3"、3.0 和 True 都会被拒。每次非终止转移消耗它,跨 resume 保留,所以一条因为预算耗尽而失败的流水线需要 fresh_restart=True / --fresh-restart:只写 resume=True 只会重放同一个致命错误。硬杀后还标 running 的行只有 resume=True 才会被认领;否则这次运行跳过它,不分叉一条遍历出去。
"Handoff.rewind 抛 FatalError。" 目标必须是已声明的、严格更早的任务,用唯一任务名或它的 seq 指定。自回退、end、流水线没声明的目的地都是编写错误,所以是致命错误、永不重试;retry_all 同样需要它的源出现在 control.retry_all 中,不接受任何值。
接下来该看什么¶
想找某个特定功能而不是整篇文档?见开头附近的 找到你需要的内容。
| 资源 | 里面有什么 |
|---|---|
docs/reference.md |
每个公开类和函数:签名、参数、示例 |
docs/cli.md |
每个子命令和 flag、退出码、配置文件参考 |
docs/design.md |
概念模型、六条不变量、租约契约、数据模型、权衡 |
README.md |
精简导览:调度保证、分片、插件、不在范围内的内容 |
examples/quickstart.py |
60 行讲完 SDK,含一轮恢复 |
examples/llm_eval/ |
完整评测、两种流水线形态、实测的检查点粒度 |
examples/sharded.py |
一个数据集分布在 N 个存储上,然后合并报告 |
examples/plugin_package/ |
可安装的插件:任务、一个算法、一个编解码器 |
examples/qa_eval.yaml |
声明式路径,端到端 |