API 参考¶
English | 简体中文
pyattacker 里每个公开名称的签名、参数和用法示例。
想找其他内容? docs/tutorial.md 一步步带你上手;
docs/cli.md 讲命令行用法;docs/design.md 解释框架为什么长这样。
此处使用的约定¶
- 目录表里列的所有东西都能从顶层包导入:
from pyattacker import Runner。少数辅助类型由这些 API 返回,但本身没导出(PoolStats、DeclarativeSpec、TaskRecord、RunRecord、Store协议);遇到这种情况本页会给子模块名,你一般不用手动 import。 - 签名按源码里的写法。签名里的
*表示后面所有参数都是仅限关键字(keyword-only)参数。 - 这里的默认值很重要。 两个尤其容易踩坑:
Retrying(max_attempts=1)意味着除非你主动要求,否则不重试;Runner(store=":memory:")意味着除非传路径,否则什么都不持久化。 - 有些示例是完整程序。 第一行是
# reference/<name>.py的代码块会在每次测试时被写出并执行 (见tests/test_docs_examples.py)——README 和 CLI 文档共享这个约定。没标记的代码块是片段。
目录¶
| 章节 | 名称 |
|---|---|
| 任务 | task, build_task_spec, TaskSpec, Retrying, TaskContext, with_retry |
| 流水线 | pipeline, PipelineTemplate, PipelineSpec, Chain, compute_spec_digest |
| 交接 | Handoff、control= 声明、HandoffRecord |
| 反向遍历 | Handoff.rewind、Handoff.retry_all、反向的 control 键、访问、预算、恢复 |
| 运行 | Runner, RunConfig, RunReport, worker 存活检测 |
| 资源 | Resource, Pool, Lease, PoolStats, Bus, ResourceState, ResourceEvent |
| 获取算法 | Wait, Backoff, LeastBusy, Failover, Sticky, QuotaAware, Immediate, resolve_algorithm |
| 错误 | 异常层次结构、error_class_of |
| 工件与编解码器 | Artifact, Codec, CodecRegistry, HistoryArtifact, JsonCodec, BytesCodec, Encoded, canonical_json, digest_of |
| 存储 | open_store, SqliteStore, MemoryStore, 记录类型 |
| 工件后端 | InlineBackend, FileBackend, NullBackend, resolve_backend |
| 分片与合并 | shard_index, in_shard, shard_specs, shard_store_path, parse_shard, merge_reports, MergedReport |
| 导出 | iter_rows, export_store, export_stores, ROW_KINDS, FORMATS |
| 声明式配置 | load_spec, DeclarativeSpec |
| 插件 | PLUGINS, PluginRegistry, list_plugins |
| 内置任务 | echo, fanout, flaky, delay, boom, leaky, simulate_llm, shell_run, write_jsonl, jsonl_source, seed_factory |
| 监控 | StatsServer |
任务¶
任务是一元函数:一个值进,一个值出。可以同步可以异步,接受 (value) 或 (value, ctx)。其他签名装饰时就抛 ConfigError。
task¶
@task(name=None, *, resource=None, algorithm=None, retry=None, timeout_s=None, config=None, version=None) -> TaskSpec
把函数变成 TaskSpec。可以裸用(@task)、带名字用(@task("ask"))、或者带选项用。
| 参数 | 类型 | 默认值 | 含义 |
|---|---|---|---|
name |
str |
函数名 | 任务在记录和事件里的名字 |
resource |
str |
None |
没指定名字的 ctx.acquire() 调用用的默认资源池 |
algorithm |
str 或算法 |
None |
默认获取策略;回退到资源池自己的策略 |
retry |
Retrying 或 dict |
不重试 | 某次尝试抛异常时的策略 |
timeout_s |
float |
None |
单次尝试的墙钟时间上限;仅异步任务 |
config |
JSON 映射 | {} |
声明的行为,会快照进恢复指纹 |
version |
str |
None |
外部行为或动态代码的显式版本号 |
from pyattacker import Retrying, task
@task("prepare") # sync, no context
def prepare(seed: dict) -> dict:
return {"q": seed["question"]}
@task("ask", resource="apis", algorithm="backoff",
retry=Retrying(max_attempts=4, base=0.5, cap=30.0), timeout_s=60)
async def ask(row: dict, ctx) -> dict: # async, with context
async with ctx.acquire(model="gpt-4o") as lease:
return {"a": await lease.client.chat(row["q"])}
同步任务设 timeout_s 会被接受但没用——同步函数占着事件循环,框架没机会取消它。阻塞工作放到 await asyncio.to_thread(...) 后面。
任务需要的不止是值和上下文?从闭包里拿:
def make_judge(model: str, threshold: float):
@task(f"judge.{model}", resource="judges", config={"model": model, "threshold": threshold})
async def judge(row: dict, ctx) -> dict:
async with ctx.acquire(model=model) as lease:
return {**row, "pass": await lease.client.score(row) >= threshold}
return judge
pipeline("eval", prepare | make_judge("gpt-4o", 0.8))
build_task_spec¶
build_task_spec(fn, *, name=None, resource=None, algorithm=None, retry=None,
timeout_s=None, registry=None, config=None, version=None,
children=(), parameters=None) -> TaskSpec
@task 背后的函数。装饰时还不知道目标就直接调它——声明式层就是这么处理 use: 的。传一个已有的 TaskSpec 加覆盖项,返回新 spec,不是就地改它。
TaskSpec¶
任务的不可变描述。你很少自己构造它;@task 交给你,你再传给 pipeline(...)。
| 属性 | 含义 |
|---|---|
name |
任务在记录里的名字 |
fn |
被包装的可调用对象 |
resource, algorithm, retry, timeout_s |
声明的各项策略 |
accepts, returns |
类型提示,构建期链校验用 |
takes_ctx |
fn 是否接受 (value, ctx) |
module, qualname, code_digest |
标识代码这个版本;并入流水线摘要 |
config, version |
显式声明的行为和版本 |
parameters, children |
工厂参数和嵌套 spec;和用户 config 分开记录 |
| 方法 | 返回值 |
|---|---|
is_async |
fn 是否为协程函数 |
fingerprint(*, include_code=True) |
给 spec_digest 的字典;含嵌套 spec |
runtime_algorithm() |
从捕获的同一性配置派生的运行时算法 |
with_overrides(**kwargs) |
替换了指定字段的新 spec |
with_overrides 有三种情形,都对用户可见:
- 没传的关键字保持当前值;
- 显式的
None会清空可空字段——resource、algorithm、timeout_s、version。工厂自己的resource=或algorithm=就是这么被去掉的; UNSET(导出为pyattacker.UNSET)即使键存在也表示"没传",所以总是发同一组键的构建器可以转发自己的字典,不清空调用方没提的东西。声明式加载器就是这么做的。
不可空字段(name、fn、retry、children、config、parameters)传 None 会抛 ConfigError,不会静默什么都不做。清空 config 意味着 config={};清空 children 意味着 children=()。
两个 TaskSpec 用 | 组合成 Chain。spec | other 本身不做任何校验;校验发生在 pipeline(...) 里。
Retrying¶
Retrying(max_attempts=1, on=(), retry_classified=True, retry_unknown=False,
base=0.5, factor=2.0, cap=30.0, jitter="full", max_total_s=None)
| 字段 | 默认值 | 含义 |
|---|---|---|
max_attempts |
1 |
含首次的总尝试次数——默认不重试 |
on |
() |
额外视为可重试的异常类型 |
retry_classified |
True |
重试可重试类别(rate_limit、timeout、connection、upstream、retryable) |
retry_unknown |
False |
也重试 unknown——ResourceUnavailable 需要它 |
base, factor, cap |
0.5, 2.0, 30.0 |
延迟为 min(cap, base * factor**(attempt-1)),然后加抖动 |
jitter |
"full" |
"none"、"full" 或 "equal" |
max_total_s |
None |
已用时间 + 下一次延迟超过这个值就放弃 |
Retrying(max_attempts=5, base=0.5, cap=30.0) # typical API client
Retrying(max_attempts=3, on=(MyProviderError,)) # add your own exception type
Retrying(max_attempts=3, retry_unknown=True) # retry capacity shortages too
Retrying(max_attempts=10, max_total_s=120.0) # bounded by time, not just count
接受 Retrying 的地方都接受 dict,YAML 形式就靠这个:
retry={"max_attempts": 3, "on": ["RetryableError"]}。
| 方法 | 返回值 |
|---|---|
should_retry(exc, error_class=None) |
这个策略下这个异常可重试吗 |
delay_for(attempt, rng, retry_after=None) |
延迟秒数;服务端给了建议就以 retry_after 为准 |
TaskContext¶
双参数任务收到的 ctx。Runner 按每次尝试构造;永远不用你构造。
| 属性 | 含义 |
|---|---|
pipeline_id, run_id, task_name, seq |
正在跑的活的身份 |
attempt |
从 1 开始的尝试序号——if ctx.attempt > 1: 就是你检测重试的方式 |
bus |
这次运行的 Bus |
store |
这次运行的存储,需要读历史的任务用 |
meta |
自由形式的字典,每次尝试一份 |
ctx.acquire¶
返回一个产出 Lease 的异步上下文管理器。这是用资源的推荐方式——
每条退出路径都归还租约,包括异常、取消和超时。
| 参数 | 含义 |
|---|---|
pool |
资源池名或对象;默认用任务的 resource= |
algorithm |
这次调用覆盖获取策略 |
timeout |
没及时拿到资源就抛 AcquireTimeout |
where |
Callable[[Resource], bool],选择器表达不了的谓词 |
**selector |
按 id、kind、tags、options 匹配,含点路径 |
async with ctx.acquire(model="gpt-4o") as lease: # by option
...
async with ctx.acquire("judges", id="judge-a", timeout=30) as lease: # explicit pool, bounded wait
...
async with ctx.acquire(where=lambda r: r.options["ctx_len"] >= 32000) as lease:
...
默认 wait 算法下,匹配不到任何资源的选择器会永远等。宁可报错不要挂着,就传
timeout= 或用 algorithm="immediate"。
| 其他方法 | 用途 |
|---|---|
await ctx.acquire_lease(...) |
返回裸 Lease 的逃生通道;你必须自己还。忘了还的租约任务结束时强制回收,记 lease.leaked |
ctx.publish_resource(pool, resource) |
运行时加资源;其他流水线立刻能租 |
ctx.revoke_resource(pool, resource_id, reason="") |
撤回某个资源 |
ctx.subscribe(pool, events=None) |
资源池事件的异步迭代器 |
ctx.held_leases() |
这次尝试当前持有的租约 |
ctx.reclaim_now() |
强制归还当前持有的全部租约;同步、不可中断 |
ctx.emit(kind, **data) |
把你自己的事件写进这次运行的事件流 |
ctx.report_metric(name, value, *, label="", display="number") |
汇报当前流水线作用域的最新值 |
@task("adaptive", resource="apis")
async def adaptive(row: dict, ctx) -> dict:
if ctx.attempt > 1:
ctx.emit("my.retrying", attempt=ctx.attempt, reason="previous attempt timed out")
async with ctx.acquire() as lease:
return {"a": await lease.client.chat(row["q"])}
with_retry¶
某个任务改了重试策略后的副本——不同策略复用同一个任务,不用重新定义。
from pyattacker import with_retry
careful = with_retry(ask, max_attempts=6, cap=60.0)
pipeline("qa", prepare | careful | judge)
注意这会改流水线的 spec_digest,从而改身份:用
careful 构建的流水线和用 ask 构建的流水线不共享检查点。
流水线¶
流水线是任务的线性链,是完成和恢复的单位。给它种子之前它是模板;
map() 把每个种子变成独立的 PipelineSpec。
pipeline¶
pipeline(name, *tasks_or_chain, tags=None, include_code=True, registry=None, control=None) -> PipelineTemplate
| 参数 | 含义 |
|---|---|
name |
流水线名,记在每一行上 |
*tasks_or_chain |
一条链(a \| b \| c),或多个独立参数传的 TaskSpec |
tags |
自由形式字典,随每条流水线存着,供后续过滤 |
include_code |
为 True(默认)时,每个任务的源码摘要都是流水线身份的一部分 |
registry |
非 JSON 工件类型用的自定义 CodecRegistry |
control |
(见交接):哪个任务能向哪里交接;None(默认)让流水线保持普通线性链 |
from pyattacker import pipeline
template = pipeline("qa", prepare | ask | judge, tags={"bench": "mmlu"})
template = pipeline("qa", prepare, ask, judge) # equivalent
template = pipeline("qa", ask) # a single task is a valid pipeline
链在这里校验,不是跑到一半才:prepare 返回 dict 而下一个任务要 int,
这里立刻抛 PipelineBuildError。校验看注解——接受子类,Any 或没注解视为宽松,裸容器接受参数化形式。returns 注解里的 Handoff 成员是个逃生通道:-> Handoff | Report 按 Report 校验,单独的 -> Handoff 能接任何类型(这条路径上的任务不产工件)。
include_code=False 适用于你确实想让任务体变、又不想丢现有检查点的情况。默认值走安全方向:代码变了就是新流水线。
PipelineTemplate¶
| 属性 / 方法 | 返回值 |
|---|---|
name, tags, tasks, spec_digest |
声明内容 |
n_tasks |
链里有多少个任务 |
task_names |
按顺序的任务名 |
describe() |
可转 JSON 的概要(就是 validate 打印的),有解析后的 control 块也会包含 |
control |
解析后的边计划,或 None |
bind(seed, *, key=None, repeat=0) |
从一个种子得到一个 PipelineSpec |
map(seeds, *, repeats=1, key_of=None) |
PipelineSpec 的惰性迭代器 |
map¶
| 参数 | 含义 |
|---|---|
seeds |
任何可迭代对象,包括生成器——惰性消费 |
repeats |
每个种子 k 条独立流水线:pass@k 和自洽采样 |
key_of |
Callable[[seed], str],用你自己的稳定 id 代替内容寻址 id |
runner.run(template.map(rows)) # one pipeline per row
runner.run(template.map(rows, repeats=5)) # pass@5
runner.run(template.map(rows, key_of=lambda r: r["qid"])) # ids from your dataset's primary key
def stream(): # streams seeds; max_admitted bounds unfinished pipelines
with open("dataset.jsonl") as fh:
for line in fh:
yield json.loads(line)
runner.run(template.map(stream()))
repeats>1 且指定了 key_of 时,键变成 f"{key}#{repeat}"。
PipelineSpec¶
一条等着跑的流水线:种子加链。spec.pipeline_id(也是 spec.key)是
内容寻址的身份,恢复和分片就靠它。
| 属性 | 含义 |
|---|---|
pipeline_id / key |
规格(spec)摘要、种子摘要和重复索引的 blake2b |
seed |
数据集里的一行 |
repeat |
这是 pass@k 里的第几个样本 |
name、tasks、n_tasks、tags |
继承自模板 |
control |
解析后的边计划,或 None |
Chain¶
a | b | c 产出的东西。chain.tasks 是规格组成的元组。只有你写以编程方式组合流水线的代码时才需要这个类型:
from pyattacker import Chain
steps = prepare | ask
if with_judging:
steps = steps | judge
template = pipeline("qa", steps)
compute_spec_digest¶
任务链指纹(v2: 加 32 字符十六进制摘要)。跑任何东西之前检查代码改动会不会让现有检查点失效:
from pyattacker import compute_spec_digest
if compute_spec_digest(new_chain.tasks) != stored_digest:
print("this chain will start fresh pipelines, not resume the old ones")
摘要涵盖每个任务的名字、资源、timeout_s、源码摘要,以及会改变步骤耗时的五个重试字段(max_attempts、base、factor、cap、jitter)。它有意排除 on、retry_unknown 和 max_total_s。
control 块只有存在时才折进去,所以这个特性没改变任何现有摘要:没 control 的流水线保持精确身份(因而也保持流水线 id、检查点和分片分配),和交接出现之前完全一样。声明的边以解析后的形式参与摘要——seq 目标、排好序——所以目标写成名字还是 seq 是同一条流水线。
交接(可选启用)¶
交接、正向跳转和回退都是正式的控制流特性,通过 control 显式声明后启用。 任务可以通过返回一条框架自带的指令而不是值来向前跳;流水线在声明里更靠后的位置继续(或就地结束),框架持久记录这次跳转。这里什么都不声明的流水线完全不受影响——
完整论证见设计文档
§4.8。反向遍历——Handoff.rewind、
Handoff.retry_all 和可选的载荷历史——是同一特性里单独声明的控制流方式:见
反向遍历。
Handoff¶
Handoff.to(target, value=UNSET, *, reason="") -> Handoff
Handoff.end(value=UNSET, *, reason="") -> Handoff
| 字段 / 方法 | 含义 |
|---|---|
target |
任务名、任务的 seq,或 END 时为 None |
value |
目标的入口状态。UNSET(默认)表示"复用本任务收到的工件";None 是真实的载荷 |
reason |
自由格式字符串,记在账本和 pipeline.handoff 事件里 |
is_end |
这个指令是否结束流水线 |
reuses_input |
目标是否带着本任务自己的输入工件进入 |
from pyattacker import Handoff, TaskContext, task
@task("judge")
async def judge(value: Verdict, ctx: TaskContext) -> Handoff | Report:
if value.good_enough:
return Handoff.end(value.as_report(), reason="already good enough")
if not value.needs_metrics:
return Handoff.to("report", value.as_report(), reason="metrics not needed")
return await write_report(value)
template = pipeline("qa", retrieve | ask | judge | report,
control={"edges": {"judge": ["report", "end"], "ask": ["report"]}})
用之前值得知道的规则:
- 交接是一次返回,绝不是失败。 不会问重试策略(不会加
decision原因,retry.on=(Exception,)/retry_unknown=True也没法把它变成重试),async with ctx.acquire(...)已经还了租约,被取消或超时的尝试永远走不到这个返回。strict_leases=True下,泄漏的租约会让任务失败,交接也不会被认。 - 边是声明的,不是推导的。 没
control块就返回Handoff,或者沿着不是从这个任务声明的边返回,都是FatalError(绝不重试,也绝不静默跳转)。 - 只能正向。 目标必须严格晚于源。
"end"是合法目标, 但从最后一个任务发除外:那里没效果,会被拒。 - 重复的任务名要 seq。 "ask" 出现两次会因有歧义被拒;改成以 seq
2为目标。 - 载荷不做类型检查。 交接的参数是任意值,不是源任务正常的
返回类型;它像任何工件一样用流水线的
CodecRegistry编码。 - 交接绝不会来自 N 个分支之一。
fanout会拒绝分支返回的指令, 因为记录里一个分组就是一步,一次控制转移没法归因到若干并发分支中的某一个。 - 游标变成位置。 开了控制流的流水线上,
n_tasks_done表示执行到哪, 不是跑了多少任务,跳过的槽位也没任务行。别把它渲染成完成百分比; 交接计数就在旁边露出来。 - 公共 API。
Handoff、control和上述保证都遵循项目的版本管理策略。反向遍历是一个单独声明的控制流方式,不是这个正向模型的一部分——见 反向遍历。
一次跳转记录的内容¶
| 位置 | 记录的内容 |
|---|---|
tasks.state = "handed_off" |
源任务干净结束,没产工件 |
attempts.outcome = "handed_off" |
发生跳转的那次尝试,decision 为空 |
artifacts |
入口状态:复用的工件,或位于 seq = n_tasks + k 的新载荷 |
handoffs |
账本行:from/to、entry_artifact_id、entry_reused、reason |
pipeline.handoff 事件 |
审计轨迹(强杀可能丢它;账本是权威) |
HandoffRecord(由 pyattacker 导出)就是那条账本行:handoff_id、pipeline_id、run_id、
from_seq、from_task、to_seq/to_task(END 时为 None)、entry_seq、entry_artifact_id、
entry_reused、reason、ts。
反向遍历(rewind、retry-all、visits)¶
回退和全量重试是正式的控制流特性,通过 control 显式声明后启用。 反向操作和上面的正向模型分开
声明,所以什么都不声明的流水线保留身份、访问 0 的工件地址、随机流和 spec_digest。当一个站点判定更早的某个站点必须带着作者选定的状态重跑——校验失败后重新生成、用不同参数重试某个步骤——而且两次运行都得留在记录里,不能折成一个任务,再读这节。
tutorial 用两步把它搭起来,第二步讲
载荷历史。
Handoff.rewind 与 Handoff.retry_all¶
Handoff.rewind(target, value, *, reason="") -> Handoff # explicit state is required; None is a value
Handoff.retry_all(*, reason="") -> Handoff # restart at seq 0 from the original bound seed
| 字段 / 方法 | 含义 |
|---|---|
target |
任务名或任务的 seq,始终严格早于源 |
value |
目标入口状态。rewind 要求给;retry_all 不接受任何值 |
reason |
自由文本字符串,记到交接账本和 pipeline.handoff 事件里 |
operation |
"rewind" 或 "retry_all":账本行说这次转移是什么 |
两者都像 Handoff.to 和 Handoff.end 一样从任务里返回,正向规则照旧:交接是返回值不是失败
(不问重试策略,租约已经释放,被取消或超时的尝试永远走不到这个返回),指令永远逃不出
fanout 分支,未声明或形式错的指令是 FatalError 不是静默跳转。不同点在于:
- 状态你选;框架不回滚字典。
rewind要求显式值——None是真实的值,不是"复用输入" ——而且目的地必须是已声明、严格更早的任务,用唯一任务名或它的 seq 指定。自回退和把end当 回退目标都会被拒;链里重复出现的名字必须用 seq 指定;目标之前的结果保持有效,目标及其之后的结果变成历史,各自访问重新跑。 - 正向还是正向。
Handoff.to()继续用control.edges,永远不获得隐式反向语义; 纯反向流水线,正向声明可选。 retry_all重放绑定的种子。 它从 seq 0 重新开始,种子是绑定时刻捕获的字节新鲜解码出来的 ——事后改某个任务的输入或spec.seed不改实际跑的内容。它不接受替换值:想用 不同状态重启,从更靠后的任务调Handoff.rewind(0, chosen_state)。它可以声明在第一个任务上, 包括单任务流水线;不创建另一个映射行或重复项、不重置资源、也不启动另一次 CLI 运行—— 它清空有效的任务结果,同时保留访问、工件、尝试和已消耗的控制预算。- 异常重试是另一套机制。
Retrying在同一个访问内重试一次尝试;rewind和retry_all是返回的 控制指令,绝不触发失败重试策略。编写错误和预算耗尽都是致命失败,策略没法重试。
# reference/backward_rewind.py
"""Backward traversal in one program: a validator sends the work back to the generator."""
from pyattacker import Handoff, Runner, pipeline, task
@task("generate")
def generate(state: dict, ctx) -> dict:
# <- your model call; a revisit is a genuinely new sample
return {**state, "sample": ctx.visit, "ok": ctx.visit >= 1}
@task("validate")
def validate(row: dict, ctx) -> Handoff | dict:
if not row["ok"]:
# Explicit state: the author decides what the generator receives next.
return Handoff.rewind("generate", {"prompt": row["prompt"], "feedback": "retry warmer"},
reason="invalid sample")
return row
template = pipeline(
"rewind",
generate | validate,
control={
"rewind": {"validate": ["generate"]}, # a strictly earlier destination, declared
"max_handoffs": 3, # required: the loop budget for this pipeline
},
)
with Runner(store=":memory:", max_handoffs=10) as runner: # the runtime ceiling; the lower limit wins
report = runner.run(template.map([{"prompt": "Return JSON"}]))
store = runner.store
record = next(iter(store.pipelines()))
print(report.stats["pipelines"]["by_state"], "position:", record.n_tasks_done)
print([(row.seq, row.visit, row.name, row.state) for row in store.tasks(record.pipeline_id)])
print("budget consumed:", store.visit_state(record.pipeline_id)["handoffs"])
control 块¶
| 键 | 形态 | 含义 |
|---|---|---|
edges |
{source: [later targets]} |
正向跳转(Handoff.to / Handoff.end);只声明反向操作时可省 |
rewind |
{source: [strictly earlier targets]} |
允许从每个来源发 Handoff.rewind |
retry_all |
[sources] |
允许从每个来源发 Handoff.retry_all |
max_handoffs |
正整数 | 一旦有 rewind 或 retry_all 就必需:这条流水线的控制预算上限 |
max_handoffs 和其他声明一样过校验:3.0、True、"3"、0 都会被拒,没任何反向操作
却给了 max_handoffs 也会被拒(control: max_handoffs requires backward operations)。
RunConfig.max_handoffs(默认 1000,配置里写 run.max_handoffs)是运行时上限:实际限制是
min(control.max_handoffs, run.max_handoffs),所以一次运行可以调低某条流水线的预算,但永远不能调高。
全新开始会重置它。名字和 seq 的解析和 edges 完全相同(精确任务名优先于数字字符串,重复出现的
名字必须用 seq 指定),每个问题都以配置字段路径报告——见
CLI → 反向控制声明。
# reference/backward_retry_all.py
"""Retry-all restarts the pipeline from the seed the run was bound to."""
from pyattacker import Handoff, Runner, pipeline, task
entries = []
@task("prepare")
def prepare(seed: dict, ctx) -> dict:
entries.append(ctx.visit) # a fresh entry after retry-all, not a retry of a failed attempt
return {"prompt": seed["prompt"], "prepared": True}
@task("check")
def check(row: dict, ctx) -> Handoff | dict:
if ctx.visit == 0:
return Handoff.retry_all(reason="new preparation") # source declared in control.retry_all
return row
template = pipeline("retry-all", prepare | check,
control={"retry_all": ["check"], "max_handoffs": 2})
with Runner(store=":memory:") as runner:
report = runner.run(template.map([{"prompt": "one row"}]))
store = runner.store
record = next(iter(store.pipelines()))
print(report.stats["pipelines"]["by_state"])
print("prepare entries:", entries)
print("transfers:", store.visit_state(record.pipeline_id)["handoffs"])
访问与工件发生实例¶
ctx.visit 对每个站点都从 0 开始,每次全新进入该站点递增——回退后的普通后继进入也算——
ctx.attempt 编号的是同一个访问内的尝试。两者合起来让一次重新生成保持可见,不是藏起来。
| 位置 | 说明 |
|---|---|
pipeline_id:seq |
访问 0 的工件 id;重访用 pipeline_id:seq#visit |
TaskRecord.visit、AttemptRecord.visit、Artifact.visit |
这行是哪个发生实例 |
store.get_artifact(pipeline_id, seq) |
该站点有效的输出,在它当前的访问 |
store.get_artifact_by_id(artifact_id) |
一个精确的历史发生实例,包括已被取代的那些 |
store.visit_state(pipeline_id) |
游标、待定入口、有效槽位、按 seq 的计数器、已消耗的交接数 |
PipelineRecord.n_tasks_done |
反向流水线里的位置,不是完成度计数 |
访问也参与派生随机性:RNG 和 ctx.seed 在访问 0 时和旧派生方式逐字节一致,重访把访问计入,
所以一次重新生成采出不同的样本。它们不会让外部副作用变成恰好一次——每次重新生成都该是新的
外部操作时,把 ctx.visit 纳入幂等键(见外部副作用)。
待定入口引用它确切的输入,恢复保留它的访问并继续已消耗的尝试编号:尝试编号在任务代码跑之前就 保留,所以硬杀可能在已完成的尝试行里留空缺,但永远不会重用某个编号。未提交的工作,框架的恢复 保证是至少一次。
预算与终止¶
反向遍历在结构上不再有限,这正是预算必须显式且强制的原因。开反向的流水线上每次非终止转移都
计数,包括正向的 edges 转移,而且N 恰好允许 N 次转移:第 N+1 次在发布转移或使结果失效之前
被拒,所以记录还是精确描述已提交的内容。END 到了上限可以直接完成,不消耗另一次转移。
已消耗的计数跨 resume、全部重试和载荷缺失时的自动种子回退保留;只有显式的全新开始才开新的 预算生命周期。访问和审计行也在这次重启里保留,所以历史发生实例仍然能寻址。
恢复与所有权¶
打开一条已存储的行做什么取决于那行,规则是给人读的,不是让人猜的:
| 已存储的行 | 这次运行会做什么 |
|---|---|
failed、interrupted |
普通检查点恢复:精确的持久化访问——访问编号、待定入口、已消耗的尝试编号——继续 |
running,resume=True |
操作者声明上一个所有者已经消失。interrupt_stale 先回收心跳过期的行;然后精确的持久化访问继续 |
running,没 resume |
跳过,绝不接管:持久化的待定访问可以在崩溃后继续,所以第二个写入者会把一次遍历分叉。pipeline.skipped 带 reason="owned_by_another_run" 和所有者的运行 id |
| 行已持久化,遍历消失 | 拒绝:corrupt visit checkpoint: missing traversal state。fresh_restart=True 是文档给的丢弃重来方式 |
succeeded |
跳过,除非 retry_succeeded=True;这时重启从绑定种子以全新预算运行 |
fresh_restart=True 是唯一会丢持久化进度的开关:它清空有效的遍历和任何待定入口(把留下还在飞
中的每个任务行结算为 interrupted),从不可变的绑定种子重新开始,重置控制预算并使先前的账本失效
——同时保留访问计数器和带访问限定的审计行,所以历史发生实例仍然能寻址,遍历丢了的存储从这些
行重建计数器。它也适用于正向流水线,意思是"忽略检查点,重跑整条链":追加式历史
也会保留,但任务和链上工件的地址按设计重用,不是留作独立的发生实例(
存储恢复契约 把这区别讲清楚了)。和 retry_succeeded=True 结合,可以重启
一条已经成功过的流水线。全新开始发 pipeline.restarted(带被丢弃的游标),不是
pipeline.checkpoint_missing:什么都没丢。
待定载荷缺失或不可用会发 pipeline.checkpoint_missing 并建一次种子重放,保留预算和计数器。
流水线首次执行永远不走这条路径——它的输入就是它已经持有的绑定种子,所以 journal="summary"
存储(它写的种子载荷被有意丢掉)不会报一次它从未有过的检查点故障。摘要日志和 null 后端可以
在进程里跑循环,但恢复不了它们缺的载荷。指向 seq 0 的待定回退用它选定的载荷,不触发
种子重置。反向转移通过定时器泵重新排队,把 worker 让给其他流水线。
检视一次反向运行¶
只有开反向的流水线,它的流水线导出才多一条 control 遍历记录。它包含 cursor、pending、有效
的 active 槽位、持久化的 counters、已消耗的 handoffs、version、精确的当前 input 和 terminal
引用。嵌套的任务行和工件行含 id、访问和 active 标记,单独的任务/尝试/工件导出含 visit
(尝试还带它们的 task-run id)。既有的顶层导出行种类这个闭集没变。HTTP /pipelines 视图对
开反向的行含遍历状态和 cursor_kind="position";纯正向的行两者都没有。
报告的作用域是它覆盖的那次运行,统计那次运行里重复的访问和尝试,所以 resume 后它显示 新运行的工作量,存储和导出保留此前每一行。这些合计是工作量,不是完成百分比。
存储能力¶
本节背后的可选存储方法——visit_state、reset_visits、commit_entry、commit_visit_attempt、
commit_visit_success、commit_control_transition、repair_visit_terminal 和 get_artifact_by_id
——在存储 → 可选的存储能力里说。它们在基础 Store 协议之外,所以第三方后端
对普通流水线仍然可用;在没过探测的存储上打开开反向的流水线,开始之前就被拒抛
ConfigError,绝不会降级成非持久循环。
同一项特性还管存储的特性级别和随之的兼容性规则:存储一直停在 base,直到首次提交
重访;打开未知级别的构建直接拒该存储;visits-v1 的 SQLite 存储启用写入保护机制,
防不具备谱系感知的写入者。见存储 → 存储兼容性与备份。
运行¶
Runner¶
Runner(*, store=":memory:", pools=(), concurrency=16, clock=None, bus=None,
registry=None, config=None, **config_overrides)
调度器。它管着存储、资源池和 worker 槽位。任何 RunConfig 字段都可以
直接当关键字参数传。
from pyattacker import Runner
with Runner(store="runs/qa.db", pools=[pool], concurrency=64) as runner:
report = runner.run(template.map(rows))
print(report.summary())
当上下文管理器用。退出它也关存储,所以要在 with 块内部读基于文件的存储
里的行,或者之后用 open_store 重新打开那个文件。
| 方法 | 用途 |
|---|---|
run(specs, *, resume=False, **overrides) |
跑至完成,返回 RunReport。在 asyncio.run 里包 run_async |
await run_async(specs, *, resume=False, **overrides) |
同上,但在已有的事件循环里 |
stats() |
实时快照;运行中途安全调 |
report_metric(name, value, *, label="", display="number", pipeline_id=None) |
汇报应用计算的最新值 |
stop(reason="user") |
请求运行优雅停:不再接新活,排空在途工作 |
stopping |
是否在停 |
run_id |
当前运行的 id |
add_pool(pool) / pool(name) |
注册或拿资源池 |
close() |
关存储(上下文管理器会做) |
report = runner.run(template.map(rows), resume=True) # resume
report = runner.run(template.map(rows), concurrency=8) # override for one call
live = runner.stats() # while running
print(live["in_flight_pipelines"], live["delayed_pipelines"]) # in flight vs parked in backoff
run() 仅在准入容量可用时读取下一个 PipelineSpec。
max_admitted=None(默认)解析为 4 * concurrency;也可设为正整数,限制排队、执行和
等待重试的流水线总数。重试保留原有准入资格,暂停新输入时仍能回到 worker。
较小的上限减少未完成工作持有的内存,但退避时可能让 worker 空闲;低于 concurrency 时
也会限制实际并行度。Suite 成员共享一个 Runner 的预算;各分片进程分别计算预算。
这限制的是未完成调度状态的数量,不是总字节数或进程 RSS。内存还包括保留的 seed/当前值、
源自身的缓冲、worker 引用、write-behind 缓冲、缓存及报表查询。默认 MemoryStore 会随
运行持续保留记录和工件载荷;大数据集应使用文件存储和流式源。预先构造的输入列表以及
没有大小限制的任务载荷都不在准入保证范围内。
runner.stats() 暴露 admitted_pipelines(已接纳减已终结)及实际 max_admitted;
运行记录的 config 也保存实际生效的上限。停止、取消和运行预算都会解除准入等待。
同步读取输入和阻塞任务仍须先交还控制权,异步调度器才能响应。
RunConfig¶
决定一次运行形态的一切。传一个 RunConfig,或者把它的字段当关键字参数传给 Runner。
| 字段 | 默认值 | 含义 |
|---|---|---|
store |
":memory:" |
":memory:"、SQLite 路径、插件 URI,或已打开的存储实例 |
journal |
"full" |
"full" 留工件载荷(任务级恢复必需);"summary" 只留元数据 |
concurrency |
16 |
最多同时在途的尝试数;挂在重试退避里的流水线不占槽位 |
max_admitted |
None |
排队 + 执行 + 延迟流水线总上限;默认 4 * concurrency,可设正整数 |
label |
"" |
记在这次运行上的标签 |
run_id |
None |
显式运行 id;默认时间戳 + 摘要 |
resume |
False |
调度前把被已死运行遗弃的流水线标成可恢复 |
retry_succeeded |
False |
重跑已标成功的流水线。只判定资格:绝不丢未成功流水线的检查点或遍历记录 |
fresh_restart |
False |
让已接纳的流水线从绑定种子重新开始:丢检查点/遍历记录,重置控制预算。只追加的历史保留;反向流水线还留访问发生实例和计数器 |
heartbeat_s |
5.0 |
这次运行的心跳多久写一次 |
grace_s |
5.0 |
优雅关闭在取消 worker 之前等多久 |
stale_after_s |
30.0 |
某次运行的心跳早于这个时长,它正在跑的流水线视为已遗弃 |
strict_leases |
False |
泄漏的租约让任务失败(LeaseLeakError),不悄悄强制回收 |
stop_after_failures |
None |
N 次失败后停接工作(尽力而为) |
stop_after_s |
None |
过了这么长墙钟时间后停接工作 |
handle_signals |
True |
装 SIGINT/SIGTERM 处理器,调 stop() |
write_behind |
None |
批处理只追加事实;None 表示对基于文件的存储开启 |
write_batch |
128 |
write-behind 生效时的批大小 |
flush_interval |
1.0 |
两次刷写之间的秒数 |
artifact_backend |
None |
载荷放哪:None/"inline"、"file:///path"、"null",或规格字典 |
max_handoffs |
1000 |
开反向的流水线中非终止控制转移的运行时上限;实际限制为 min(control.max_handoffs, this)(见 反向遍历) |
notes、meta |
""、{} |
自由形式,记在这次运行上 |
from pyattacker import RunConfig, Runner
config = RunConfig(store="runs/qa.db", concurrency=64, journal="full",
strict_leases=True, stop_after_failures=50)
with Runner(config=config, pools=[pool]) as runner:
report = runner.run(template.map(rows))
stop_after_failures 在接纳时和每次完成时求值,所以已经接纳的流水线还会跑完——
它是止血,不是让时间倒流。
RunReport¶
run() 返回的东西。
| 属性 / 方法 | 含义 |
|---|---|
run_id、status、duration_ms |
运行的身份和结果 |
stats |
完整统计字典,含 stats["pipelines"]["by_state"] |
skipped |
多少流水线被跳过:因为它们已成功,或者(反向遍历时)某条 running 行属另一次运行而 resume 没认领它 |
leases_leaked |
多少租约必须强制回收 |
repair_failures |
这次运行写不进最终状态、使其脱离撕裂终态的流水线;它是运行局部的,这类失败只有这里可见(该行保留原始属主) |
stop_reason |
运行为何提前停(如果确实提前停了) |
summary() |
人类可读的多行报告 |
to_dict() |
同样的事实,机器可读 |
export_jsonl(path, *, scope="store", run_id=None) |
写流水线行,返回数量 |
report = runner.run(template.map(rows))
print(report.summary()) # for a human
metrics = report.to_dict() # for a dashboard
print(report.stats["pipelines"]["by_state"]) # {'succeeded': 98, 'failed': 2}
failed = report.stats["pipelines"]["by_state"].get("failed", 0) + report.repair_failures
if report.status != "completed" or failed:
report.export_jsonl("runs/failures.jsonl")
raise SystemExit(1) # fail your CI job
Worker 存活检测¶
完成按计数判定,所以一次运行要求每条已接纳的流水线都到终态。若一个 worker 在自身处理器
之外死——一个不是 CancelledError 的 BaseException,由框架自己的代码或它调的东西
抛,比如第三方存储钩子——它再也到不了终态,这次运行本来会永远等下去。取而代之:
- 运行被硬停(
stop_reason == "worker_crashed"),其他 worker 也被切断,不再 接任何新活; - 死 worker 当时持有的流水线记
failed——如果运行已经在停,记为interrupted——逃逸的异常记在那行上;已经终态的流水线保留状态, 状态从存储读,不是运行器的内存记录(存储不用回写交给它的记录, 所以以持久化的那行为准); runner.worker_crashed事件带那个流水线、那个异常和它的回溯;run_async抛WorkerCrashed(运行记录以interrupted关后),把原始异常 作为__cause__。run()传播它,CLI 带具名错误退出,状态码 2,所以崩溃的 worker 不会看起来像一次完成的运行。
from pyattacker import WorkerCrashed
with Runner(store="runs/qa.db", pools=[pool]) as runner:
try:
report = runner.run(template.map(rows))
except WorkerCrashed as exc:
print(f"lost {exc.pipeline_id}: {exc.__cause__!r}") # the run's record is already durable
raise
由任务抛的 BaseException 永远不走这条路径(任务自己的处理像对待普通异常一样把它
收住),取消保持语义:被取消的 worker 记 interrupted 再重新抛。
KeyboardInterrupt 和 SystemExit 是唯一边界:asyncio 会在任何监督者来得及跑之前停
事件循环,所以 worker 自己记那行和事件,中断原样传播——调用方看到的是它自己的中断,
只有那条运行记录落到完不成的状态。
资源¶
Resource 是一项具体外部能力——一个端点、一把 API key、一个本地 worker。Pool 是
它们的一组,加一个默认获取策略。资源池是流水线之间唯一共享的状态。
Resource¶
Resource.create(kind="generic", *, id=None, options=None, tags=None, capacity=1,
factory=None, degrade_after=3, dead_after=8, cooldown_s=30.0, **meta) -> Resource
| 参数 | 默认值 | 含义 |
|---|---|---|
kind |
"generic" |
你自己选的类别("llm"、"gpu"、……);可参与选择器匹配 |
id |
自动生成 | 稳定标识符,记在每个租约上 |
options |
{} |
你工厂读的配置:base url、key、model。可参与选择器匹配,含点路径 |
tags |
{} |
额外的、可参与选择器匹配的标签 |
capacity |
1 |
这一个资源允许的并发租约数 |
factory |
None |
Callable[[Resource], client],每个资源惰性调一次,首次租约时 |
degrade_after |
3 |
资源被熔断前的连续失败次数 |
dead_after |
8 |
资源被标失效前的连续失败次数 |
cooldown_s |
30.0 |
降级的资源多久不参与轮转 |
from pyattacker import Resource
def build_client(res: Resource):
return OpenAI(base_url=res.options["base_url"], api_key=res.options["api_key"])
resources = [
Resource.create("llm", id=f"key-{i}", capacity=8, factory=build_client,
options={"base_url": "https://api.example/v1", "api_key": key,
"model": "gpt-4o", "quota": {"tokens": 2_000_000}},
tags={"tier": "prod"})
for i, key in enumerate(api_keys)
]
capacity 按资源计,资源池总容量是各资源之和。把那个总和和 concurrency 比:
worker 比容量多就意味着 worker 在等资源池。
工厂是 client 待的地方:它只建一次,这个资源的每个租约共享。工厂抛异常,
租约被拒抛 ResourceUnavailable(绝不会给你一个 client 是 None 的租约),同时记
resource.factory_failed 事件,反复失败让这个资源走同样的降级/失效路径。冷却
清掉已存的错误,所以偶发的工厂失败还能得到一次真正的重试。
| 属性 / 方法 | 含义 |
|---|---|
id, kind, options, tags, capacity, meta |
构造时一致 |
spec() |
可直接序列化为 JSON 的描述,密钥已掩码 |
lookup(key) |
对某个选择器 key 返回 (found, value),含点路径 |
Pool¶
Pool(name, resources=(), *, kind=None, algorithm=None, bus=None, clock=None,
deadlock_warn_s=5.0, on_event=None)
| 参数 | 默认值 | 含义 |
|---|---|---|
name |
— | 任务引用它的方式(resource="apis") |
resources |
() |
它启动时持有的资源 |
kind |
None |
之后加的资源的默认 kind |
algorithm |
Wait() |
未覆盖这个设置的任务用的默认获取策略 |
deadlock_warn_s |
5.0 |
当持有此资源池中某个资源的任务为另一个资源等这么久,发 acquire.suspected_deadlock |
on_event |
None |
每个资源池事件的回调 |
from pyattacker import Pool
pool = Pool("apis", resources, algorithm="backoff")
judges = Pool("judges", [Resource.create("llm", id=f"j-{n}", capacity=1, options={"model": n})
for n in ("judge-a", "judge-b", "judge-c")],
algorithm="least_busy")
with Runner(store="runs/qa.db", pools=[pool, judges], concurrency=32) as runner:
...
| 方法 | 用途 |
|---|---|
add(resource) |
运行时加一个;选择器匹配的等待者被唤醒 |
revoke(resource_id, reason="") |
撤回一个 |
resources() |
当前列表 |
stats(**selector) |
聚合的 PoolStats |
snapshot() |
每个资源的字典:state、leases、active、capacity |
subscribe(events=None) |
资源池事件的异步迭代器 |
print(pool.stats().utilization, pool.stats().waiting)
for slot in pool.snapshot():
print(slot["id"], slot["state"], f"{slot['active']}/{slot['capacity']}")
两者运行期间都安全调——watch 和 serve 就是这么做的。
运行途中加端点也可以,等待中的流水线会用上新的:
@task("discover")
async def discover(row: dict, ctx) -> dict:
for endpoint in await find_new_endpoints():
ctx.publish_resource("apis", Resource.create("llm", capacity=4, options=endpoint))
return row
Lease¶
ctx.acquire(...) 产出的对象。一个租约就是对某个资源的一次使用。
| 属性 | 含义 |
|---|---|
client |
工厂建的对象——你要调的就是它 |
resource |
它背后的 Resource |
options |
lease.resource.options 的快捷方式 |
held_ms |
这个租约已持多久 |
task_name |
持有它的任务 |
lease.report¶
资源池正是借此知道一切。 框架不猜某次失败是不是端点的错,所以 健康的端点绝不会因为你的 JSON 解析器出错就被熔断。
| 参数 | 作用 |
|---|---|
ok=False |
计一次连续失败,供 degrade_after / dead_after |
ok=True |
清失败计数器——这是一次恢复,能把降级或失效的资源拉活 |
latency_ms |
为每个资源维护 EMA |
usage |
累配额计数器,quota_aware 按它排序 |
error |
记下来供诊断 |
async with ctx.acquire(model="gpt-4o") as lease:
try:
started = time.perf_counter()
response = await lease.client.chat(row["q"])
except ProviderOverloaded as exc:
lease.report(ok=False, error=exc) # the endpoint's fault: count it
raise RetryableError("overloaded", error_class="upstream") from exc
except json.JSONDecodeError:
lease.report(ok=True) # our parsing bug, not the endpoint's
raise
lease.report(ok=True, latency_ms=(time.perf_counter() - started) * 1000,
usage={"tokens": response.usage.total_tokens})
return {"a": response.text}
| 其他方法 | 用途 |
|---|---|
degrade(reason="") |
立刻让这个资源退出轮转——比如你已知它配额耗尽 |
release_now() |
同步归还;逃生通道的对应手段,用 async with 时不需要 |
PoolStats¶
pool.stats() 返回(定义在 pyattacker.resource,没在顶层导出)。
| 字段 | 含义 |
|---|---|
active, capacity, utilization |
当前占用 |
ready, degraded, dead |
按状态统计的资源数 |
waiting |
当前排队的获取者数 |
leases_total, ok_total, failed_total |
吞吐 |
total, revoked |
资源数 |
leaked_total |
不得不强制回收的租约 |
waits_total, wait_ms_avg, wait_ms_p50, wait_ms_p95, wait_ms_max |
获取资源要多久 |
usage |
累积的 lease.report(usage=...) 计数器 |
wait_ms_p95 一直涨,说明瓶颈在你的资源池,不是提供方。
Bus¶
轻量跨流水线信号总线,用于协调的推送一侧。
count = ctx.bus.publish("found_answer", qid=row["qid"]) # returns subscriber count
async for message in ctx.bus.subscribe("found_answer"): # "*" for everything
...
ResourceState¶
READY, DEGRADED, DEAD, REVOKED。字符串枚举,所以日志里 str(state) 显示 "ResourceState.READY"
而线上格式(wire format)用 .value。
状态转换:连续失败 degrade_after 次后 READY → DEGRADED,持续 cooldown_s;连续失败到 dead_after
次后 → DEAD。一次成功的 report(ok=True) 把降级或失效的资源拉回 READY。冷却
到期不会重置失败计数器——只有成功才会。
ResourceEvent¶
资源池的一次状态变化,同时投递给等待者、订阅者、events 表和监控
快照。字段:kind, pool, resource_id, data, ts,加 as_dict()。
获取算法¶
算法决定怎么从资源池拿资源。它和重试是两条独立的轴,重试决定
活失败后做什么。可以按资源池设(Pool(..., algorithm=...))、按任务
(@task(algorithm=...))、或按调用设(ctx.acquire(algorithm=...))。
每个算法都能按名字用,所以 algorithm="backoff" 和 algorithm=Backoff(cap=60) 都
行;YAML 配置用的就是字符串形式。
| 算法 | 没空闲资源时 | 什么时候用 |
|---|---|---|
Wait() (默认) |
排队直到有槽位释放或 timeout 到期 |
稳态吞吐 |
Backoff() |
指数退避 + 抖动等 | 提供方已饱和;避免释放时大家一起冲 |
LeastBusy() |
选负载率最低的,之后回退 | 容量不等的多个端点 |
Failover() |
按顺序试各资源池,之后回退 | 主/备提供方、分层 key |
Sticky() |
优先用这条流水线用过的资源 | prompt 缓存、热连接 |
QuotaAware() |
按剩余配额排序 | 预算受限的端点 |
Immediate() |
立刻抛 ResourceUnavailable |
卸负载不排队 |
Wait(timeout=None)
Backoff(base=0.2, factor=2.0, cap=10.0, jitter="full", max_wait=None)
LeastBusy(fallback=None) # fallback defaults to Wait()
Failover(pools=(), fallback=None)
Sticky(fallback=None)
QuotaAware(metric="tokens", reserve=0.05)
Immediate()
from pyattacker import Backoff, Failover, Pool, QuotaAware, task
# Back off on a busy pool instead of piling up.
pool = Pool("apis", resources, algorithm=Backoff(base=0.5, cap=60.0))
# Spend the big-quota key first; declare quota in options, consume it via lease.report(usage=...).
budgeted = Pool("apis", resources, algorithm=QuotaAware(metric="tokens", reserve=0.05))
# Primary, then backup, then wait on the primary.
@task("ask", resource="primary", algorithm=Failover(pools=["primary", "backup", "spot"]))
async def ask(row: dict, ctx) -> dict:
async with ctx.acquire() as lease:
return {"a": await lease.client.chat(row["q"])}
依赖它们之前,三个行为值得知道:
Failover不循环。 它按顺序把列出的每个资源池各试一次;都没容量,它的fallback(默认Wait())只挂在pools[0]上。理解成"先试这些,最后定在 主资源池上"。QuotaAware只是偏好,不是限制。 所有候选都耗尽时,最好的那个仍会被 发出去——拒活比超支糟。要硬停,在任务里跟踪预算 然后抛异常。Immediate抛ResourceUnavailable,归类成unknown,默认重试策略 不重试unknown。这是有意的——容量上的失误就该大声暴露。想重试容量问题,把Immediate和Retrying(retry_unknown=True)搭配。
获取时的退避和重试时的退避是两个旋钮:Backoff 决定为一个槽位等
多久,Retrying 决定一次失败后等多久。
resolve_algorithm¶
把名字、字典或实例转成算法。内置算法解析先于插件,插件盖不住
wait。
写自己的算法,实现 AcquireAlgorithm 协议(pyattacker.algorithm),在
pyattacker.algorithms 入口点组下注册——见 插件 和 examples/plugin_package/。
错误¶
失败就是普通异常;没有要满足的状态机。框架对你抛的任何异常 分类,问你的重试策略怎么办。
层次结构¶
PyAttackerError
├── ConfigError a config or declaration mistake (CLI exit code 2)
│ └── PipelineIdentityConflict stored key has a different task or seed digest
├── PipelineBuildError the task chain does not type-check
├── PluginError a plugin failed to load or resolve
├── ArtifactCodecError a payload could not be encoded or decoded
├── ResourceError
│ ├── ResourceUnavailable no resource could be leased
│ │ └── AcquireTimeout ... within the timeout
│ ├── PoolNotFound no pool by that name
│ └── LeaseLeakError a lease outlived its task, under strict_leases=True
├── RetryableError you are declaring this failure retryable
├── FatalError you are declaring this failure final
├── BudgetExceeded a run budget was spent
├── RunInterrupted the run was stopped
├── CorruptCheckpoint a stored checkpoint contradicts the pipeline definition
├── StoreUnavailable the store became untrustworthy
└── WorkerCrashed a worker died outside its own handlers
错误类别¶
error_class_of(exc) 把任意异常映射成下列字符串之一:
| 类别 | 默认是否重试 | 识别来源 |
|---|---|---|
rate_limit |
是 | status 425/429 |
timeout |
是 | TimeoutError、status 408/504 |
connection |
是 | ConnectionError |
upstream |
是 | status 500/502/503/505/507/529 |
retryable |
是 | 未显式指定类别的 RetryableError |
invalid |
否 | ValueError、TypeError、… |
fatal |
否 | FatalError、任何其他 4xx |
cancelled |
否 | 取消 |
unknown |
否 | 其他一切,包括 ResourceUnavailable |
状态码依次从 status、status_code、http_status 或 code 读,回退到
exc.response.status_code——不用 import 就能覆盖常见的提供方 SDK。
from pyattacker import error_class_of
error_class_of(HttpError(429)) # 'rate_limit'
error_class_of(TimeoutError()) # 'timeout'
error_class_of(ValueError("bad")) # 'invalid'
自行掌控重试决策¶
from pyattacker import FatalError, RetryableError
# Retryable, with the class and the server's suggested delay.
raise RetryableError("rate limited", error_class="rate_limit", retry_after=response.headers["retry-after"])
# Never retried, whatever max_attempts says.
raise FatalError("the prompt exceeds the model's context window")
自己的异常类上设 error_class 属性同样有效;异常暴露 Retry-After
响应头,也会从中读 retry_after。
每个决策都持久化,所以"它为什么重试了五次 / 为什么停了"是一次查询,不是 考古。
for attempt in store.attempts(pipeline_id=pid):
print(attempt.attempt_no, attempt.error_class, attempt.decision)
# {'retry': True, 'reason': 'retryable', 'delay_s': 0.7, 'error_class': 'rate_limit', ...}
reason 是 ok、retryable、attempts_exhausted、policy_declined、total_budget 之一。
工件与编解码器¶
工件(artifact)是任务被持久化的输出。任务一成功就写,这正是让 检查点粒度是任务而不是流水线的原因。
Artifact¶
| 字段 | 含义 |
|---|---|
pipeline_id, seq |
它的身份;seq=-1 是流水线的种子。开控制的流水线上,交接载荷位于 seq >= n_tasks(见交接),所以 seq 只有对链才是任务位置。在开反向的流水线上,第一个发生实例保留 pipeline_id:seq,重访把 id 限定为 pipeline_id:seq#visit |
task_name |
哪个任务产的 |
type_name, codec |
怎么恢复它 |
digest, size |
载荷的 blake2b 和长度 |
payload |
编码后的字节;字节在后端里或被丢了时为 None |
blob_ref |
字节不是内联存放时的位置 |
is_final |
它是否是这条流水线的最终输出 |
available |
载荷是否真能读回来——恢复时查的就是这个 |
encoded() |
一个可直接交给 registry.load(...) 的 Encoded 三元组 |
artifact = store.get_artifact(pipeline_id, 1)
if artifact.available:
value = runner.registry.load(artifact.encoded())
相同载荷产生相同摘要,所以原样返回输入的任务不占额外存储。
CodecRegistry¶
决定值怎么变字节。JSON 能处理 dict、list、标量和 dataclass;给任务标 返回类型,那个类就自动注册,所以恢复出的检查点以你自己的类型返回, 不是 dict。
| 方法 | 用途 |
|---|---|
register(codec, *, for_types=(), name=None) |
加一个编解码器,可选绑定到特定类型 |
register_type(cls) |
显式注册一个 dataclass(可作装饰器) |
codec_for(obj) |
哪个编解码器会处理这个值 |
dump(obj) / load(encoded) |
编码 / 解码 |
type_name_of(obj) |
记下来的类型名 |
import numpy as np
from pyattacker import CodecRegistry, Runner, pipeline
class NumpyCodec:
name = "npy"
def can_encode(self, obj): return isinstance(obj, np.ndarray)
def dumps(self, obj):
buf = io.BytesIO(); np.save(buf, obj); return buf.getvalue()
def loads(self, data): return np.load(io.BytesIO(data), allow_pickle=False)
registry = CodecRegistry()
registry.register(NumpyCodec(), for_types=(np.ndarray,))
template = pipeline("embed", embed_task, registry=registry)
with Runner(store="runs/embed.db", registry=registry) as runner: # the same registry in both places
runner.run(template.map(rows))
注册表必须同时交给模板(用于种子)和 Runner(用于检查点)。认领某载荷的编解码器
优先于更早的注册,所以专用编解码器不会沦为 JSON 兜底背后的死代码;显式 for_types=
胜过自动扫描。
内置编解码器:JsonCodec(默认)和 BytesCodec(原始 bytes/bytearray)。把你自己的编解码器发
到 pyattacker.codecs 入口点组下,它自己就装好了。
HistoryArtifact¶
一个可选的载荷基类:让载荷自带应用状态的具名快照。它是解码后的载荷,不是持久化 Artifact 记录
的子类,runner 里也没有任何代码读它来决定下一步去哪:它存在是为了让一次
回退能送回作者选定的状态,同时它途经的那些状态仍然可检视。
普通字典还是普通字典——任务进入或完成时不自动生成快照。教程第 17 步
把它搭了出来。
| 成员 | 含义 |
|---|---|
state |
当前应用状态,分离的深拷贝 |
history |
全部快照,最旧在前,分离的记录:{id, label, state, metadata} |
selected |
restore 最近一次选中的快照 id,或 None |
checkpoint(label, *, metadata=None) |
新值,带一个分离的快照;标签唯一,snapshot: 保留给稳定 id(snapshot:0 等) |
with_state(value) |
新值,替换当前状态但不追加快照 |
snapshot(id_or_label) |
一条分离的快照记录;未知或有歧义的选择器抛 KeyError |
restore(id_or_label) |
新值,其状态是该快照,并且其 selected 指向它;整段历史都保留,后续阶段仍可检视 |
prune(*selectors) |
新值,去掉那些快照;选择器指向被选中的快照时抛 ValueError,id 永不重用 |
# reference/history_artifact.py
"""HistoryArtifact: named, detached snapshots inside an application payload."""
from pyattacker import CodecRegistry, HistoryArtifact
class GenerationState(HistoryArtifact):
pass
registry = CodecRegistry()
registry.register_type(GenerationState) # the codec restores the subclass, not a bare HistoryArtifact
state = GenerationState({"prompt": "Return JSON", "temperature": 0.2})
state = state.checkpoint("before-generation")
state = state.with_state({**state.state, "answer": "invalid"}).checkpoint("after-generation")
restored = state.restore("before-generation") # selected, with the later snapshot still in history
next_state = restored.with_state({**restored.state, "temperature": 0.7})
print("labels:", [row["label"] for row in next_state.history])
print("selected:", next_state.selected)
print("round trip equal:", registry.load(registry.dump(next_state)).state == next_state.state)
try:
next_state.prune("before-generation") # the selected snapshot cannot be pruned
except ValueError as exc:
print("prune refused:", exc)
这类载荷要遵守的规则:
- 嵌套可变值永远不和保留的快照互为别名。
state、history和snapshot(...)返回的都是分离的 拷贝,所以改当前状态改不了过去。 - 状态和 metadata 必须可 JSON 序列化。 编码会拒绝客户端、租约和其他运行时对象。
- 版本化的
history-v1编解码器同时留快照和已注册的子类类型。 子类继承基类构造函数(应用 字段放state里);自定义构造函数或额外属性的序列化不在这个接口范围,未注册的子类明确 解码失败,不是以普通HistoryArtifact身份回来。 - 持久化是提交的事,不是
checkpoint()的事。 任务里调checkpoint()不碰存储;runner 在提交任务输出或控制转移时持久化载荷,那次提交之前崩溃可能丢内存里的快照。 - 历史自包含,会增长,随快照数量和大小增长——有意识地 prune。它不取代框架的执行 账本、任务行或访问记录。
辅助函数¶
| 函数 | 用途 |
|---|---|
canonical_json(obj) |
确定性 JSON:键有序,无多余空白 |
digest_of(data) |
内容寻址用的 blake2b 摘要 |
Encoded |
一个冻结的 (type_name, codec, data) 三元组 |
存储¶
存储保存框架记的每项事实。SQLite 是默认实现;内存存储语义完全相同,正是测试该用的那种。
open_store¶
open_store(spec, *, journal="full", write_behind=None, batch_size=128,
flush_interval=1.0, clock=None, backend=None) -> Store
from contextlib import closing
from pyattacker import open_store
with closing(open_store("runs/qa.db")) as store: # reopen a finished run, any process
print(store.stats()["pipelines"])
for row in store.errors(limit=20):
print(row["name"], row["failed_task"], row["error_type"])
spec 可以是 ":memory:"、一个路径、一个插件 URI("s3://bucket/runs.db"),或已打开的存储。
支持运行写存储的同时读它——WAL 允许一个写入者多个读取者。
事实批次与失败恢复¶
pyattacker.store.FactBatchStore 是独立于必需 Store 协议的可选能力:
write_facts(attempts, events) 原子提交两个序列,成功后才回填 ID。
抛出异常时,批次不能有已提交行,传入记录的 ID 也必须保持不变。
该能力自行管理事务;若 SQLite 已有事务,调用会被拒绝,不会提交或回滚调用方的工作。
空批次不创建事务。
WriteBehindStore 优先使用这个能力。SQLite 实现了它,Suite 两种布局下包装的 SQLite
存储也一样。失败的原子批次保留在缓冲里,可以再次 flush。
分目录 Suite 中,较早成功的子库批次不会因后续子库失败而回滚;重试只写剩余批次。
连接淘汰先 flush、后移出缓存,因此 flush 失败时,子库连接和待写批次仍可用于重试。
检查点、工件和任务状态仍绕过缓冲同步写入;交接和访问事务的边界不变。
没有 write_facts 的旧存储仍通过逐条 record_attempt/emit_event 调用工作。
每次正常返回就从缓冲移除该条已确认记录。调用抛错时,原异常向上传播,失败的那条及尚未调用的后缀留在缓冲中。
但失败调用可能已经提交后才抛错,因此该包装器随后会拒绝继续写入事实,以及依赖 flush 的读取或刷新操作,
抛出以原异常为原因的 StoreUnavailable。buffer_stats()["flush_blocked"] 表示此状态;
pending 包含提交结果不确定的那条记录。重新构造包装器前,需要将已持久化记录与最初提交的事实核对,
不能直接重放待写后缀。对这类旧存储的模糊提交结果,框架不保证自动 exactly-once。
能保证原子回滚的自定义存储应实现可选批量能力。定制事实写入的 SQLite 子类还需要覆盖 write_facts,
或设置 write_facts = None,继续使用其逐条写入方法。
关闭时即使 flush 抛错也会释放底层存储,错误仍向上传播。 Suite 会尝试关闭所有子库、目录库和写入锁,并传播第一个错误。 关闭失败后应按需重新打开并核对记录,不能在已经关闭的连接上重试 flush。
表与读取器¶
| 表 | 每行对应 | 读取方式 |
|---|---|---|
runs |
一次运行 | store.get_run(id) |
pipelines |
一条流水线:状态、检查点游标、摘要、标签 | store.pipelines(...), store.export_rows() |
tasks |
一个任务:最终状态、已用尝试次数、耗时、错误 | store.tasks(...) |
attempts |
一次尝试:结果(succeeded/failed/timeout/cancelled/handed_off)、错误类别、重试决策、租约、耗时 |
store.attempts(...) |
artifacts |
一个工件 | store.artifacts(pid), store.get_artifact(pid, seq) |
results |
流水线最终结果、状态、错误和实验归属 | 同流水线顺序 |
handoffs |
一次交接:起点/目标位置、入口工件、是否复用、原因 | store.handoffs(...)(可选能力),嵌套在 export_rows() 中 |
events |
一个结构化事件 | store.events(...) |
resources |
一个资源:规格(spec,密钥已脱敏)和健康统计 | 包含在 stats() 中 |
| 方法 | 返回值 |
|---|---|
stats(run_id=None) |
计数、状态分布、延迟百分位;handoffs_total 统计记下来的跳转次数(普通运行为 0) |
errors(*, run_id=None, limit=20) |
失败项,含任务名、错误类型和消息 |
export_rows(*, run_id=None) |
嵌套的流水线行:含任务、工件和交接;每行都带 attempts_total,merge_reports 就是从它计数的 |
attempts(*, pipeline_id=None, ...) |
尝试历史 |
events(*, pipeline_id=None, run_id=None, kind=None, limit=...) |
事件流,可按事件类型过滤 |
count_events(*, kind=None, run_id=None, pipeline_id=None) |
匹配事件的精确计数,通过聚合查询(不物化日志) |
close() |
关连接 |
# "Why did this pipeline take 40 seconds?"
for attempt in store.attempts(pipeline_id=pid):
print(f"{attempt.task_name} #{attempt.attempt_no} {attempt.outcome} "
f"{attempt.duration_ms}ms class={attempt.error_class} {attempt.decision}")
# The full story of one pipeline.
print([event.kind for event in store.events(pipeline_id=pid)])
带类型化字段的记录类型:PipelineRecord、AttemptRecord、EventRecord 和 HandoffRecord
已导出;TaskRecord、RunRecord 和 Store 协议在 pyattacker.store.base。SqliteStore 和
MemoryStore 是这些实现,open_store 是你拿实例的方式。
journal="summary" 留摘要和元数据,不留载荷。它省空间,代价是丢任务级
恢复——没有可恢复的工件时,被恢复的流水线从头跑,记
pipeline.checkpoint_missing。
存储的游标也会被修复,不是盲目信。如果一条 failed/interrupted 流水线的
n_tasks_done 已经等于 n_tasks_total,它属于被撕裂的收尾(存储故障,或最终写入期间被杀):
每个任务都写过检查点,所以下次运行像普通恢复那样校验最后一个工件——
存在、载荷留着、能解码——然后标为最终工件,写终止行的最终状态(状态、
游标、所属运行,以及清掉的失败字段;存储提供可选
settle_pipeline 能力时一次写完),记 pipeline.terminal_repaired,带上那行原来
的状态/错误/运行,然后以 succeeded 结束,不重跑任何东西。两种情况不会修复,而且不一样:越过末尾的游标不是 Runner 能造的状态,所以报
CorruptCheckpoint(pipeline.corrupt_cursor),永不升成成功,存储的值原样留着
当证据;而缺、无载荷或解不出的终止工件,回退到
普通的从零重启规则(pipeline.checkpoint_missing / pipeline.checkpoint_unusable)。一次修复
如果在收尾的任一步失败——最终标记或终止行的最终状态写——那行就保持
不变,包括原始失败和所属运行,记带阶段信息的
pipeline.terminal_repair_failed,所以后续尝试仍报原始原因。这样的行从不属执行修复的那次运行,
失败永远不出现在按运行范围统计的 stats 里;它计入
RunReport.repair_failures,正是这让 CLI 退出码为 1,不是报一次干净运行。
事后跑的 pyattacker report <store> 现在会显示失败的终端修复尝试:它从事件日志里查询
报告范围内的流水线是否有 pipeline.terminal_repair_failed 事件,并打印
Terminal repair failures: N pipeline(s) 段落,列出每个受影响的流水线和失败阶段。
计数按 pipeline_id 去重,所以同一条流水线出现在多个存储里不会重复计数。
实时 run 的退出码仍然是它刚完成的那次尝试的权威信号;事后报告是历史视图。
开控制的流水线加一个分支,在那些规则之前查
(交接):如果最新活动账本行的目标等于游标或在它前方,
运行用记录的入口工件从那个目标恢复,绝不重跑源任务;持久的
END 行根据它自己的入口工件写最终状态。目标在游标后方的行已被
后续正向进展消费,所以走普通的 artifact(cursor - 1) 规则。如果
入口载荷没了(journal=summary、null 后端、删了的 blob),流水线从种子
重启记 pipeline.checkpoint_missing,和丢了线性检查点一样。
从 seq 0 重启(显式传 fresh_restart=True、已在 retry_succeeded=True 下成功过的流水线,
或不可用的检查点)会在任务跑之前,持久地把
PipelineRecord.handoff_floor 推进到最新的账本 ID。等于或低于这个
水位的行仍留在只追加的历史和导出里,但没法驱动新执行的恢复。
对开控制的流水线,reset_pipeline(record) 在提交游标/水位的同时,
删此前的当前任务行和链工件(0 <= seq < n_tasks)。种子和高位段
载荷工件,以及只追加的 attempts/events/handoffs 都留着;留着的工件,最终标记会被
清。所以 tasks() 和链工件导出描述的是当前这次执行,包括没行的
被跳过站点。历史上复用的入口地址可能引用已删/已替换的链槽位;
账本留的是来源信息,不是那些槽位的不可变快照。
fresh_restart=True 是唯一会故意丢检查点的开关,对反向和正向
流水线一视同仁:流水线从绑定种子重跑,这次打开跳过 resume/修复规则,
这次打开记 pipeline.restarted(带它丢的游标),不是
pipeline.checkpoint_missing,因为没丢任何东西。所有只追加的都留着——attempts、events、
handoffs,对反向流水线还有访问计数器,所以历史发生实例仍可寻址。
因此框架不再能用的检查点不是死路,耗尽的
反向遍历预算也不是;见恢复与所有权。相比之下,
retry_succeeded=True 只放宽哪些流水线有资格再跑——它绝不丢
未成功流水线的状态。
水位在后续恢复和进程重启后保留;按当前 run_id 过滤会
在第二次被中断的恢复后错丢一个有效交接。提供交接的自定义存储
必须在流水线的读写里持久化这个字段,提供原子重置能力。以可写方式打开 SQLite 用
默认值 0 迁移旧数据库;只读工具把缺的列当 0,不迁移。
成功完成为每条流水线选恰好一个 is_final 工件,清较早执行的
旧最终标记。历史载荷和账本行仍可查。
所以 mark_final 在契约上幂等,工件已经是
最终工件时直接跳过。
没 settle_pipeline 能力的存储用两次写入做同样的修复,两步
被有意区别对待:失败的终止状态转换是修复失败(可重试,如
上文),失败的元数据清理不是——那行已经持久地 succeeded,流水线
已修复,只是失败文本陈旧。第二种发 pipeline.terminal_cleanup_failed,
不计为失败,因为已成功的行会永远跳过,清理永远
重试不了。已成功行上的陈旧失败元数据是这类存储有文档说明的降级保证;
内置后端一次写完该行的最终状态,永远遇不到这种情况。
分页读取与第三方存储¶
上面的列表方法是必需接口,它们可以物化结果——
这正是让 merge_reports 和一份小报告好写的原因。必须保持内存有界的
整类读取(比如大存储的导出)走 iter_* 辅助函数:
| 辅助函数 | 按此顺序产出 |
|---|---|
iter_pipelines(store, *, run_id=None, state=None) |
PipelineRecord,先按 created_at 再按 pipeline_id |
iter_tasks(store, pipeline_id=None, *, run_id=None) |
TaskRecord,按 pipeline_id、seq,再按 task_run_id |
iter_attempts(store, *, run_id=None, pipeline_id=None) |
AttemptRecord,按 attempt_id(写入顺序) |
iter_events(store, *, pipeline_id=None, run_id=None, kind=None) |
EventRecord,按 event_id(写入顺序,最旧在前),可按事件类型过滤 |
iter_artifacts(store, *, pipeline_id) |
某条流水线的 Artifact,先按 seq 再按 artifact_id |
每种顺序都以唯一键结尾,不是装饰性细节:tasks 以
task_run_id 为键,artifacts 以 artifact_id 为键,所以 (pipeline_id, seq) 和 seq 在契约上
并不唯一。分页游标是严格的 > 比较,只按非唯一前缀分页会悄悄丢
和某页最后一行并列的每一行。pipeline_id(主键)、event_id 和
attempt_id(单调计数器)本身就唯一。
from pyattacker.store import iter_events
for event in iter_events(store, run_id=run_id): # one batch in memory, not the whole log
...
PagedStore 是让那些读取分批的可选扩展:存储要实现
上面这些签名的 iter_pipelines / iter_tasks / iter_attempts / iter_events / iter_artifacts,
每次查询最多读 ITER_BATCH_SIZE(1000)行,按文档所述顺序产出。
SqliteStore 用键集分页实现全部五个方法(WHERE <key> > <last row of the batch>
ORDER BY <key> LIMIT 1000),所以没有查询返回超过一批,也没有读取游标在
处理某一行时开着。MemoryStore 遍历它的活动容器;对它来说,内存有界是固有 的。
读还在写的存储。 每页是它自己的语句——没有 长期读事务,也没有整个存储的时间点快照。迭代器 保证什么取决于它的键是否单调:
events和attempts以高水位为界(MAX(event_id)/MAX(attempt_id)取自匹配的行,读第一页时定)。此后追加的行不属 这次遍历,长时间导出不追移动的尾巴;新迭代器能看到它们。这两类 都是只追加的,所以水位是键区间的真实快照。pipelines、tasks和artifacts是尽力而为的遍历:没有可用于定界的单调键 (created_at由调用方给,task_run_id/artifact_id不按时间排序),所以 游标前方插入的行可能出现在导出里,后方插入的不会。对活动存储的 导出是"我遍历期间存在且可达的一切",不是 快照;要可复现就导出完成的存储。
对不实现该扩展的存储,兼容性规则如下——第三方存储 插件层是公开 API,现有插件针对列表方法写:
- 每个
iter_*辅助函数存在时用存储原生的分页方法,否则 委托给列表 API(iter_pipelines→pipelines()、iter_tasks→tasks()、iter_attempts→attempts()、iter_artifacts→artifacts(),以及iter_events→events(), 用它能表达的最大 limit,因为该列表 API 自己的limit意思是"最近的 N 条",表达不了"全部"); - 这个回退是对的,但物化整类数据,所以第三方存储能得完整导出, 内存特征和它的列表 API 一样。实现这五个方法才能升级它;
Store仍是open_store()唯一查的协议,加这个扩展不破坏任何东西。WriteBehindStore实现了它,像它其他读取视图一样在每次分页读取前刷新。
可选的存储能力¶
commit_handoff(record, *, task, attempt, payload=None, cursor, final=False),
handoffs(*, pipeline_id=None, run_id=None, limit=None) 和 reset_pipeline(record) 是
交接功能背后的可选能力,和 resources() 一样"不属于协议"。这次提交是一次原子
写入:把源任务终结为 handed_off,插入被交接的尝试,持久化载荷
并在它链上的地址分配,追加账本行并移动游标——对 END,还把
入口工件标为最终工件,把流水线的最终状态写为 succeeded。原子性是这项能力的要求,
不是额外的好处:这里刻意不设第二套恢复协议,所以不能把该操作作为单一单元完成的存储,
不得暴露该方法;在这样的存储上打开声明了 control 的流水线快速失败,抛
ConfigError 指出缺的 commit/reset 能力,不写一次非持久的跳转。handoffs() 读账本时
最旧在前,limit 留最新 N 条(仍最旧在前),和 events/attempts 一样。supports_handoff(store)
是探测函数:它解开 WriteBehindStore,后者转发这个能力时先刷缓冲的
attempts/events,通过这个提交写被交接的尝试,不走自己的缓冲区。
失败时必须在任何后续事件或清理写入之前回滚数据库写入。外部 blob 后端
失败后可能留一个未引用的 blob;但它不能让部分提交的检查点可见。
支持交接的存储还必须在读写里留 PipelineRecord.handoff_floor(见
上文重启规则)。
两个内置后端都实现了它;不实现它的第三方存储跑不了开控制的
流水线,它上面的普通流水线不受影响。
reset_pipeline(record) 必须原子地删此前的当前任务行和链工件槽位,
清其余工件的最终标记,持久化重置后的流水线行,含游标和水位。
留种子、高位段载荷和只追加的历史。它只在开控制的种子启动时跑;
在某个目标处恢复保持当前状态不变。WriteBehindStore 在重置前先刷。缺
这个方法的存储不能宣称支持交接;普通的无控制流水线仍支持。
settle_pipeline(pipeline_id, *, state, n_tasks_done, run_id) 是另一项可选能力,同样
和 resources() 一样"不属于协议":一次写入,把某行移到终止状态、
重新绑定所属运行,并清失败字段。终止游标修复完一条
流水线时(见上文 § 存储),Runner._settle_succeeded 用它,因为那里的写入如果被撕裂,可能
在该行写最终状态之前丢原始失败。SqliteStore 和 MemoryStore 实现了它,
WriteBehindStore 透传它,因为它是状态写入,不是批处理事实。没它的存储仍
可用:Runner 回退到 finish_pipeline(原子写状态和游标),然后一次清理用的
upsert_pipeline,最坏情况是已写最终状态的行还带着旧失败文本。
visit_state(pipeline_id), reset_visits(record, seed, *, fresh_budget=False),
commit_entry(task), commit_visit_attempt(pipeline, task),
commit_visit_success(pipeline, task, attempt, artifact, *, final),
commit_control_transition(record, *, pipeline, task, attempt, payload, entry_id, target_task, limit),
repair_visit_terminal(record) 和 get_artifact_by_id(artifact_id) 是可选能力,
在反向遍历背后,同样在协议外。store/visits.py 的 VisitStore 持
共享的状态转换语义,两个内置后端都从它派生,所以后端只需提供一个原子
写入边界,加自己的底层行写入。每个操作是一次提交:入口分配推进
按 seq 的访问计数器并记待定输入,普通成功一并写输出的发生实例和
有效槽位,控制转换还使活动后缀失效、消耗一个
预算单位,并分配目标入口。一次控制转移把它的源访问与尝试、入口发生实例、账本行、控制计数和分配好的目标入口
一起提交;开反向的流水线上一次正向转移同样消耗预算,但不向后移游标。SQLite 用
BEGIN IMMEDIATE 串行化这些能力事务,MemoryStore 在一次能力写入失败时恢复自己的状态:写入失败的
blob 发不出它的引用,回滚的数据库事务可能留一个无人引用的 blob。supports_visits(store) 是探测函数;它解开
WriteBehindStore(后者同步委托这些操作之前先刷),要求有访问
方法、feature_level() 加上 commit_handoff、reset_pipeline 和 handoffs,因为一次反向
转换通过这同一次提交写它的账本行和源任务。feature_level() 是必需的,
不是可选的:下面的兼容性规则是这项能力的一部分,不是额外要求。没过
探测的存储打开开反向的流水线时被拒抛 ConfigError,绝不降级为
非持久循环。
存储兼容性与备份¶
SQLite 对旧存储的升级是追加式的:访问列默认 0,出一张遍历状态表,记一个 store_meta 特性
级别。纯正向工作永远不离开 base。级别在给某站点分配第一个第二次发生实例的同一个事务里变成
visits-v1,永远不回退——审计行不删,它们存在过这个事实也不删。
那也是谱系不感知的写入者开始读不懂该存储的时刻,所以 visits-v1 的 SQLite 存储启用
写入保护机制:任何还没声明有访问谱系感知的连接对 pipelines、tasks 和 artifacts 做
INSERT/UPDATE/DELETE 都会显式失败(no such function:
pyattacker_store_requires_visits_aware_writer)。原始读取和旧式读取不被拦——守卫保护的是状态,
不是访问——但不懂重访谱系的构建去解读它不受支持:这样的读取者判不出哪个发生实例有效,
所以它的输出描述的是这些行,不是这次执行。守卫保证破坏性那一半:在这个特性之前发布的写入者
没法悄悄改错的发生实例,它第一次写就失败。打开自己不认识级别的构建直接拒该存储
(StoreFeatureUnsupported,只读也一样),不报一套它看不见的谱系。
实际后果:
- 备份。 对运行中的数据库用 SQLite 自带的备份 API(
sqlite3 <store> ".backup <copy>",或Connection.backup()),写入者在跑时它是安全的;把活动数据库文件连同它的-wal/-shm边文件一起拷,只在没写入者时才可靠。SQL 转储同样能恢复——sqlite3 <store> .dump | sqlite3 <copy>先写表数据、后建守卫触发器,所以不具备感知的连接也能重放它,副本连同守卫 和特性级别一起继承过去。 - 从
sqlite3命令行写受守卫保护的存储,需要在该连接上注册守卫函数(或删触发器);两者都在 受支持的接口之外。 - 唯一受支持的回到
base的方式,是由懂该级别的构建执行迁移。 - 多个 runner 同时跑同一条逻辑流水线不是受支持的调度模式;彼此独立的分片行仍独立(没
resume时running行绝不被接管,见恢复与所有权)。
工件后端¶
后端决定载荷的字节放哪。无论哪种后端,存储都留摘要、大小和编解码器,所以 工件对上层所有组件来说还是同一个对象。
| 后端 | 行为 |
|---|---|
InlineBackend() (默认) |
载荷存存储里 |
FileBackend(root=..., min_bytes=262144) |
达到或超过 min_bytes 的内容写按内容寻址的文件,读时再水合(hydrate) |
NullBackend() |
留摘要,丢字节 |
from pyattacker import FileBackend, Runner
with Runner(store="runs/qa.db", artifact_backend=FileBackend(root="/data/blobs")) as runner:
...
# Equivalent, and what the CLI and YAML accept:
Runner(store="runs/qa.db", artifact_backend="file:///data/blobs")
Runner(store="runs/qa.db", artifact_backend={"kind": "file", "root": "/data/blobs",
"min_bytes": 262144})
文件按内容寻址并原子写,所以相同载荷合并成一个文件,共享的根目录安全服务多次运行。 通过已溢写的检查点恢复是透明的。
NullBackend 让你丢恢复粒度,和 journal="summary" 完全一样:没载荷就没
可用于恢复的检查点。blob 文件缺失时的行为也是刻意如此——available 变 false,工作重做,
不静默跳过。
resolve_backend(spec) 把字符串或 dict 转成后端。FileBackend.stats() 报告它写了
什么。你自己的后端需要独立 URI scheme,通过 pyattacker.stores 分发它。
分片与合并¶
一个存储只接一个写入者,所以横向扩展是多进程各自持存储,事后再合并。 分片分配是内容寻址的流水线键的纯函数,同一份数据集总是按同样方式切,恢复的流水线回到 拥有它的那个分片。
| 函数 | 用途 |
|---|---|
shard_index(key, count) |
某个流水线键属于哪个分片 |
in_shard(key, index, count) |
它是否属于当前这个分片 |
shard_specs(specs, index, count, *, key="pipeline_id") |
把规格(spec)流过滤成单个分片 |
shard_store_path(base, index, count) |
runs/qa.db → runs/qa.shard0of4.db |
parse_shard(text) |
"0/4" → (0, 4),带校验 |
from pyattacker import merge_reports, shard_specs, shard_store_path
specs = list(template.map(rows))
paths = []
for index in range(4):
mine = list(shard_specs(specs, index, 4))
path = shard_store_path("runs/qa.db", index, 4)
with Runner(store=path, pools=make_pools(), concurrency=16) as runner:
runner.run(iter(mine))
paths.append(path)
merged = merge_reports(paths)
print(merged.summary())
merged.export("runs/all.jsonl")
分配是 blake2b(key) % count,不是 Python 里加盐的 hash(),所以它在不同进程和机器之间
稳定。均衡是统计意义的:12 条流水线分 3 片得 6/2/4 很正常,规模大了趋于
均匀。实践中你通常让 CLI 派生进程——--shards 4 --jobs 4——只有自己驱动集群才
用这些函数。
merge_reports¶
按 pipeline_id 去重(最优状态胜出,最晚完成者打破平局),然后根据存活下来的行重算工作量
计数:pipelines、tasks、attempts_total、handoffs_total 描述的是并集,所以合并幂等——同一个
存储被算两次,或者分片数变了之后同一条流水线同时出现在两个分片里,都不会把它们放大。
source_events_total 是刻意的例外,名字就说明了这一点:事件不挂在流水线行上,合并后的行里没有
任何东西能说清两份副本里哪一份拥有某个事件。它是你传入那些源库的原始总数,重复的部分照样算。
从行里读计数,会加强对自定义存储 export_rows() 的要求,所以这两个字段值得说清楚:
attempts_total是必需的。 它是流水线行的核心部分(PipelineRecord.attempts_total);缺这个字段 的行会抛ConfigError,并指明是哪一行、来自哪个源,而不是悄悄按0计数。- 嵌套的
handoffs账本是可选的,因为账本本身就是可选能力。行里没有嵌套它时,只要存储自己有handoffs(pipeline_id=...),就从那里读——嵌套本来就来自同一个能力;两样都没有的存储就是没有跳转, 记0,这是实话。
MergedReport 成员 |
含义 |
|---|---|
rows |
合并后的流水线行 |
duplicates |
被折掉的行数 |
sources |
哪些存储参与 |
attempts_total、handoffs_total |
从存活的行重算,已去重 |
source_events_total |
所给源库的原始事件日志行数,未去重 |
events_total |
source_events_total 的废弃别名,只在对象上(1.0 移除) |
stats() |
同一批计数,外加重算的流水线与任务统计 |
summary() |
人类可读的摘要 |
errors(limit=20) |
所有分片的失败 |
export(path, *, fmt="jsonl", kind="pipelines") |
写出合并后的视图 |
恢复同一性¶
任务指纹记它的名字/目标、声明的资源和超时、每个重试字段
(含异常的模块名/限定名)、任务算法配置、config、version
以及工厂 parameters。fanout 还记它的有序子指纹和 on_error。
内置工厂自动记它的行为参数。用户的 config 和工厂参数彼此独立,所以声明式覆盖
抹不掉内置任务的行为同一性。
config 和 parameters 只接受 null、bool、int、有限 float、字符串、列表,以及键为字符串的
对象;它们在构建规格时被快照。元组、非字符串对象键、JSON 原始类型的 Python 子类以及
循环引用都被拒,不被强制转换。任意闭包、客户端、全局变量、导入的辅助函数、端点选项,
以及资源池默认算法都不查。来自这些来源的行为必须显式声明:
from pyattacker import task
@task("ask", config={"model": "model-a", "temperature": 0.2}, version="prompt-v2")
async def ask(row, ctx):
... # use the same declared model, temperature and prompt revision in your client call
内置任务算法在构造 TaskSpec 时捕获规范化后的配置。
Runner 从同一份快照构建全新的运行时实例,所以之后对传入的算法实例或映射做的修改
不改已记录的行为。嵌套 fallback 被快照,
对 Sticky、LeastBusy、Failover 和 QuotaAware,隐式 Wait fallback 规范化为显式 Wait()。
Failover 的元组/列表资源池名序列刻意规范化为 JSON 列表。
自定义任务算法必须提供返回同一严格 JSON 取值域的 fingerprint(),否则
任务必须提供 version=,算法行为变了就递增它。钩子结果随规格
捕获,在任务执行前和每次获取前检查,含自定义
fallback;一旦漂移,在该算法跑之前失败。自定义钩子必须描述配置,
不是不断变化的计数器,它的配置在一次获取期间必须稳定。
仅依赖 version 的自定义算法必须支持独立的 deepcopy;定义在构造时复制,
为每个任务生成彼此分离的运行时副本。内置的字符串、
config 映射和等价实例规范化为同一指纹。机密信息和运行时客户端绝不放进同一性配置。
pipeline(..., include_code=False) 递归移源码摘要,含 fanout 子任务。
它留工厂参数、config、version 和各项策略。源码检查失败时改用
模块名/限定名,所以动态定义的函数需要显式 version 区分同名的
实现。改导入的辅助函数同样要更新 config/version。
默认 ID 对规格摘要、种子摘要和重复索引哈希。定义/输入没变时,
ID 和分片不变;行为变了产新 ID,可能迁到另一个分片。
显式的 bind(key=...)、map(key_of=...) 和声明式的 source.key_field 留传入的
ID,但 Runner 在跳过或恢复之前查已存的规格和种子摘要。
不匹配抛 PipelineIdentityConflict(ConfigError,CLI 退出码 2),中断新运行,
冲突流水线已存的定义、结果和检查点保持原样。
已经打开的进行中流水线立刻以 interrupted 收尾,持久化的
检查点游标保留;监控不用等之后恢复来修它们的状态。
改动过的工作用新 key 或新存储。retry_succeeded=True 不能当冲突的覆盖开关。
升级现有存储¶
新规格摘要带 v2: 前缀;所有默认流水线 ID 都从旧指纹
格式变。旧记录仍可读/导出,但不自动迁移或复用:
旧指纹缺验证等价性所需的信息。打开一个存储,它最旧的流水线
带旧版摘要,就在任务开始前发警告和 run.legacy_identity 事件。
默认 ID 会重跑工作;显式旧 key 会冲突。用旧版本的包完成耗时的旧运行,
然后为 v2 启新存储。别通过重写旧版摘要绕过验证。
分片运行,这次身份变更也改分片分配;完成旧运行时,
把旧分片存储和旧包放一起用。
外部副作用¶
恢复跳过检查点游标和工件都持久化的任务。它不保证 请求或文件写入恰好执行一次:提供方可能已经完成请求,进程随后崩, 或者写检查点时失败,于是这个任务可能再跑。journal 用 summary、 载荷为 null/缺失以及检查点不可用时,也可能重放更早的任务。
你的提供方支持幂等键,就从稳定的流水线身份和任务身份派生一个键,
比如 f"{ctx.pipeline_id}:{ctx.seq}",多次重试之间复用它,别把
尝试序号也加进去。开反向的流水线上,每次重新生成都该是新操作,就要把 ctx.visit
加进去,比如 f"{ctx.pipeline_id}:{ctx.seq}#{ctx.visit}"。交接载荷不是
任务身份,所以绝不要
用工件地址当外部工作的键。遵守提供方的保留窗口和 API 契约。文件汇聚器,
按身份 upsert,或为每条流水线/每个任务写一个原子替换的文件;单纯追加式
write_jsonl 任务重放时可能产重复行。资源租约安全不能让这些
外部副作用变幂等。
导出¶
六种行形态,三种格式。行以有界批从存储流式读出;合并报告 (把 N 个存储汇成一个连贯答案)是唯一必须把行留内存的地方。
| 名称 | 值 |
|---|---|
ROW_KINDS |
("pipelines", "tasks", "attempts", "events", "artifacts", "results") |
FORMATS |
("jsonl", "json", "csv") |
| 函数 | 用途 |
|---|---|
iter_rows(store, *, kind="pipelines", run_id=None, limit=None) |
以 dict 流式出行 |
export_store(store, path, *, kind="pipelines", fmt="jsonl", run_id=None, limit=None) |
写单个存储,返回行数 |
export_stores(paths, path, *, kind=..., fmt=..., limit=...) |
把多个分片存储写成一个拼接文件 |
每种 kind 一行,顺序就是 limit 截断依据的顺序:
kind |
每行对应 | 顺序 |
|---|---|---|
pipelines(默认) |
一条流水线,嵌套——含任务、工件和交接 | created_at,然后是 pipeline_id |
tasks |
一个任务:最终状态、耗时、错误、用的租约 | pipeline_id、seq,再是 task_run_id |
attempts |
一次尝试,含每次重试的 decision |
attempt_id(写入顺序) |
events |
一条结构化事件 | event_id(写入顺序,最旧在前) |
artifacts |
一个工件,含中间工件 | 流水线顺序,然后是 seq,再是 artifact_id |
results |
流水线结果:key、repeat、状态、最终 payload 和错误;保留失败或中断的记录 | created_at,然后是 pipeline_id |
limit 对所有 kind 含义相同——它数的是该 kind 的行数,artifacts 也一样,
而过去这里数的是流水线:
None(默认):完整历史,不截断;0:没行;N > 0:按上述顺序的前 N 行;- 负值:
ConfigError。
(export_stores 对每个存储分别应用 limit,所以每个存储最多贡献它的前 N 行。)
每种排序都以一个唯一键结尾,所以跨批次边界的读取不丢行也不
重复——即使很多流水线共享同一个 created_at,或若干任务或
工件共享同一个 (pipeline_id, seq) / seq,schema 不禁这种情况。
from pyattacker import export_store, iter_rows
# Compute your own metric — the framework stores facts and leaves semantics to you.
correct = sum(1 for row in iter_rows(store, kind="pipelines")
if row["state"] == "succeeded" and row["artifacts"][-1]["payload"]["correct"])
print(f"accuracy: {correct / total:.1%}")
# Retry analysis in a spreadsheet.
export_store(store, "attempts.csv", kind="attempts", fmt="csv")
pipelines 行是嵌套的——含任务、工件和交接——这也是它成默认的原因。
handoffs 列表含 handoff_id 和提交它的 run_id;流水线行含
handoff_floor。高于该水位的 ID 属活动执行历史;等于或低于它的 ID 是
历史记录,恢复时排除。store.handoffs(pipeline_id=...) 含所有记录。
stats(run_id)["handoffs_total"] 只统计这次运行提交的交接,所以恢复能用上次运行留的活动交接,
同时报告零个新交接。普通流水线,
handoffs 列表是 [];否则每记一次跳转就一个对象(见
handoffs);它是嵌套结构,不是独立的行 kind,所以 ROW_KINDS
不变。CSV 从最初几行取表头,
之后出现的键折进 extra 列,所以内存平稳,没字段
被静默丢。
导出活动中的存储。 导出不是事务:它一次读一页,所以能保证什么取决于 kind(细节见
stores)。
events 和 attempts 受导出开始时取的单调键高水位限制——之后写的行不含,
重新导出就能看到它们。
pipelines、tasks 和 artifacts 是尽力而为的遍历:先于游标写的行可能出现,
晚于游标的不会。要可复现的文件,导出完成的存储。
流式在哪里成立、哪里不成立。 jsonl 和 json 逐行写,csv 只缓冲
生成表头需要的 header_rows 前缀。存储一侧,tasks/attempts/events 通过分页辅助函数
以 ITER_BATCH_SIZE(1000)行为一批读,artifacts 先给流水线分页,
再流式读每条流水线的工件,所以内存单位是一条流水线,不是整个存储。一条
pipelines 行本身嵌套,所以导出该 kind 时一次物化一条流水线的任务和工件。
merge_reports 是刻意的例外:按 pipeline_id 去重需要每条流水线的胜出行,
所以合并后的行留内存(唯一不从这些行读的计数是 source_events_total,
它走各源库自己的聚合查询,而不是物化日志)。
声明式配置¶
load_spec 把 YAML/TOML/JSON 文件读成资源池、一个流水线模板和一个种子来源,返回
DeclarativeSpec(定义在 pyattacker.declarative)。文件描述组装和资源;
你的逻辑仍在 Python 里,由 use: 指向。
解析器按文件后缀选,只有 YAML 解析器是可选的:.json 和 .toml 用标准库
读,.yaml/.yml 需要 yaml 额外依赖(extra,pip install "pyattacker[yaml]")。
没它时,load_spec 抛一个同时指出文件和该额外依赖的 ConfigError——检查发生在
读该文件时,所以只碰 JSON/TOML 的进程永远不用装 PyYAML。
from pyattacker import Runner, load_spec
spec = load_spec("qa.yaml") # raises ConfigError on a bad file
print(spec.describe()) # what `pyattacker validate` prints
with Runner(pools=spec.pools, **spec.run) as runner:
report = runner.run(spec.pipelines(limit=100))
load_spec 是共享的校验入口:run、validate 和每个 --shards 子进程都走它,
被它拒的配置在存储出现之前以退出码 2 结束。它查未知字段、字段类型、数值
范围、资源池引用(含 use: 工厂自己声明的 resource)、算法名称和
参数,以及 source: 声明——总是指出字段路径。它只做声明检查:
不开任何数据集,也不构造工件后端。docs/cli.md
列了覆盖的内容。
配置没提的字段保留 use: 目标声明的值;显式写 field: null 清
resource、algorithm、timeout_s 或 version。这条规则和它的 SDK 写法
(TaskSpec.with_overrides)是同一个。
DeclarativeSpec 成员 |
含义 |
|---|---|
template、pools、run、source |
解析出的各小节 |
unresolved_env |
没解析出值的 ${VAR} 引用 |
seeds() |
种子可迭代 |
pipelines(*, limit=None) |
PipelineSpec 流 |
describe() |
可直接转 JSON 的摘要 |
load_spec(path, strict_env=True) 在 ${VAR} 未设时抛异常不是给警告——值得在 CI 用,
因为 CI 里静默为空的 API key 比任务失败更糟。
两个细节值得知道:
use:按形态解析。带冒号的名字(my_pkg.tasks:ask)直接导入;裸名字 (echo)先在内置任务里找,然后才查插件——所以插件永远盖不住内置任务。返回TaskSpec的可调用对象当工厂,用args/kwargs调。- YAML 1.1 把裸的
on:键解析成布尔值true。 retry 块里写"on": [RetryableError]。 加载器会发现这个错误并提示你。
完整文件格式见 docs/cli.md。
插件¶
插件就是普通的 importlib.metadata 入口点。装个包,它的名字任何配置里都能用。
| 组 | 提供的内容 |
|---|---|
pyattacker.tasks |
一个 TaskSpec,或返回它的工厂 → use: my_task |
pyattacker.algorithms |
一个获取算法 → algorithm: my_algo |
pyattacker.codecs |
一个编解码器,创建第一个 Runner 时装上 |
pyattacker.stores |
以 URI scheme 为键的存储工厂 → store: "s3://bucket/runs.db" |
[project.entry-points."pyattacker.tasks"]
my_judge = "my_pkg.tasks:my_judge"
[project.entry-points."pyattacker.algorithms"]
my_algo = "my_pkg.algo:MyAlgorithm"
from pyattacker import PLUGINS, list_plugins
for item in list_plugins():
print(item["group"], item["name"], item.get("error", "ok"))
print(PLUGINS.errors()) # {name: reason} for everything that failed to load
内置项先解析,导入时抛异常的插件记下来不传播——所以坏插件
既不拖垮一次运行,也不静默失败。pyattacker plugins 打同样的
信息。PluginRegistry 是类型;PLUGINS 是进程级实例。完整可用的示例包
见 examples/plugin_package/。
内置任务¶
其中三个用于生产(fanout、shell_run、write_jsonl);其余都是模拟工作,让你没网络也能
演练整套机制。配置里它们带 mock.* 名字:use: pyattacker.tasks:flaky。
fanout¶
一个任务内部,对 同一 输入并发跑多个任务,返回 {task_name: value}。这就是
不把流水线变成 DAG 的前提下,表达真分支步骤的方式。
from pyattacker import fanout, pipeline
judges = fanout(judge("judge-a"), judge("judge-b"), judge("judge-c"), name="judges")
@task("reduce")
def reduce_scores(row: dict) -> dict:
branches = list(row.values())
return {"qid": branches[0]["qid"],
"verdicts": {b["judge"]: b["verdict"] for b in branches}}
template = pipeline("eval", prepare | ask | judges | reduce_scores)
三个后果,都是有意的:
- 重试粒度是分组级的。 一个分支失败重试整个 fan-out;子任务各自的策略
不逐分支生效。分组采用最宽容的子任务策略,除非你传
retry=。 - 分支共享父任务的上下文,所以它们的租约和事件都记在 fan-out 任务之下—— 每个步骤一条完整记录。
- Runner 只看到分组规格(spec),所以只有每个子任务都一致时,
resource、algorithm和timeout_s才从子任务提取。这就是为什么上面三个 judge 必须声明相同的资源池和 算法。
权衡:一个检查点里有三个请求,意味着失败后恢复时重发全部三个。某个请求
开销大,就把它单独作为一个任务——examples/llm_eval/ 对两种形态都做了测量。
shell_run¶
跑子进程并返回它的 stdout/stderr。
# The argv form: no shell, and "{value}" arrives as one literal argument.
shell_run(["python", "postprocess.py", "--input", "{value}"])
命令需要工件时,用 argv 形式。 它通过 create_subprocess_exec 跑,所以替换后的
值无论含什么字符,都作为一个字面量参数传给子进程。字符串形式直接拒绝 {value},
不把它插进 shell 命令行。替换只是普通子串替换,不是 str.format(),所以其他花括号
(比如 jq 过滤器、字典字面量)原样留着。
保证是"没有隐式 shell",不是"对任何程序都安全":你的 argv 本身调了
解释器(["sh", "-c", ...]),那个解释器怎么处理输入,你自己判断。
进程生命周期。 任务拥有它启动的进程,每条退出路径都执行同一套清理——
包括进程创建在内。OS 子进程在 create_subprocess_exec /
create_subprocess_shell 返回句柄之前就已存在,落在这个窗口的取消
不允许把它弃之不顾:任务继续等句柄,处置子进程,然后才让取消继续。
正常退出不受干预(结果照常返回,check=False 仍报非零 returncode,
不抛异常)。timeout_s 到期、协程被取消(Runner 停、分组任务超时、
外层 asyncio 取消)或有其他异常逃逸,仍在跑的子进程被 SIGKILL 杀,
然后被 回收,之后异常才继续传回调用方:取消仍以 CancelledError 到达,
超时仍以 TimeoutError 到达,但它们身后不留任何正在跑的进程。
没有优雅的 SIGTERM 窗口——清理不等子进程结束。等 OS 报退出有上限
(5 秒),只对 OS 永远不报已退出的进程有意义;清理自己遇到的任何问题
(communicate() 被取消而处于坏状态的读取器、一次失败的信号)都不允许替换调用方的
异常:清理是尽力而为,
调用方的错误类型不是。
后代进程。 POSIX 上,每个子进程在自己的会话里启动(start_new_session=True),所以
清理向整个进程组发信号,不只针对一个 PID。字符串命令,覆盖流水线或
子 shell 的每个部分;argv 形式,覆盖该程序和该程序派生的任何进程。
没有"只杀直接子进程"的模式,也没有进程组之外的 cgroup/pidfd 机制。
Windows 上,标准库不支持向进程组发信号(os.killpg 不存在,asyncio 没法
向子进程的进程组发 CTRL_BREAK_EVENT),所以那里只终止直接子进程,
shell 命令的后代可能在任务结束后继续活——这是有文档的局限,
只在 POSIX 上验证过。POSIX 上子进程自成会话首进程,它不收
Ctrl-C 等终端产生的信号;停它的是任务自己的取消。清理只在子进程
本身尚未被回收时起作用:已经正常退出的命令不被追查,所以它刻意留的
进程(shell 的 &、守护进程)不被杀——子进程一旦被回收,
它的 PID(也是进程组 id)可能已被复用,
所以向那个进程组发信号本来也不安全。
write_jsonl¶
作为最后一步,把每个工件追加到 JSONL 文件。这是若干导出机制中的一种——
export_store 和 report.export_jsonl 通常更合适。
模拟任务¶
| 任务 | 签名 | 行为 |
|---|---|---|
echo |
echo(是规格,不是工厂函数) |
原样返回该值 |
flaky |
flaky(fail_times=2, *, error="retryable", message=..., retry=None) |
失败 N 次后成功——用来演练重试 |
delay |
delay(seconds=1.0) |
等待,观察并发 |
boom |
boom(message="boom", *, error="retryable") |
总失败;error 可以是 retryable、fatal 或 invalid |
leaky |
leaky(*, pool=None) |
故意泄漏租约,用来演示强制回收 |
simulate_llm |
simulate_llm(*, latency_ms=5.0, fail_rate=0.0, error="rate_limit", tokens=32, resource=None, **selector) |
获取 → 等待 → 上报 → 返回:演示资源池和退避的主力 |
from pyattacker import Runner, pipeline, simulate_llm
# A pool exercised at a 30% failure rate, with no network anywhere.
template = pipeline("smoke", simulate_llm(resource="apis", fail_rate=0.3, latency_ms=20))
把 ctx.clock.sleep 换成一次 HTTP 调用,simulate_llm 就成真任务——它就是按这种形态
写成的完整示例。
种子辅助函数¶
| 函数 | 用途 |
|---|---|
jsonl_source(path, *, limit=None) |
把 JSONL 文件当种子流式读,每行对应一条流水线 |
seed_factory(kind="range", *, n=10, path=None, limit=None) |
配置的 source: 块最终解析成的实现:range 或 jsonl |
from pyattacker import jsonl_source
runner.run(template.map(jsonl_source("dataset.jsonl", limit=500)))
监控¶
应用汇报的指标¶
应用可以在运行期间汇报实验指标的最新值。Pyattacker 只负责保存和展示,指标由应用计算。
可运行的准确率示例见 examples/live_metrics.py。
runner.report_metric("evaluated", completed, label="Evaluated")
runner.report_metric("accuracy", correct / completed, label="Accuracy", display="percent")
# Inside a task, ctx.report_metric("phase", "scoring", display="text")
Runner(..., on_pipeline_finished=callback) 在流水线终态持久化后调用
callback(runner, record, artifact)。成功时传入最终 Artifact,失败或中断时传入 None。
传入的 runner 可用于 report_metric(),也可以用
runner.registry.load(artifact.encoded()) 解码保存的产物。回调在调度线程中运行,
应尽快返回。回调异常记录为 monitor.callback_failed,不会改变流水线结果。进程崩溃可能
导致回调遗漏或重放,因此应用应按 pipeline ID 去重,并在需要时从持久化的最终产物重建
统计。恢复运行使用新的 run ID,汇报值也属于新的作用域。
report_metric(name, value, *, label="", display="number", pipeline_id=None) 接受字符串、
布尔值或有限数字。display 可为 number、percent(传入比例,展示为百分比)或
text(字符串)。同一运行、同一作用域中的同名指标会覆盖旧值。ctx.report_metric(...)
自动使用当前 pipeline 作为作用域。即使启用事件延迟写入,指标仍同步写入。第三方存储
可以选择支持此功能;对不支持的存储汇报时抛出 StoreFeatureUnsupported。
/metrics?run_id=... 返回 {"run_id": ..., "rows": [{"run_id", "pipeline_id", "name",
"value", "label", "display", "updated_at"}, ...]}。加上 pipeline_id=... 可读取该流水线的
自定义值;/pipelines 的每行也包含 reported_metrics 数组。未指定 run ID 时,
read_snapshot()、watch 和 StatsServer 的各端点统一选择最近启动的运行,包括只汇报
流水线级值的运行。HTML 面板先从 /stats 确定 run ID,再用同一个 ID 查询 /metrics、
/pipelines 和 /events。需要全库运行统计时可用 ?run_id=all(watch/serve 使用
--run-id all);全库视图不会任意挑一个运行的自定义指标来展示。HTML 面板将运行级值显示为卡片;终端
watch 也会展示。HTTP 服务仍只读,指标通过正在运行的 Runner 的存储连接写入。
runner.stats()¶
进程内实时快照。运行中途安全调。
live = runner.stats()
live["in_flight_pipelines"] # attempts actually running
live["delayed_pipelines"] # parked in a retry backoff, holding no worker
live["counters"] # admitted / succeeded / failed / done
live["pools"] # per-pool occupancy, health and wait percentiles
StatsServer¶
无依赖的只读 HTTP 视图。它为每个请求开一条全新的只读连接,所以和正在跑的 任务并排跑。
from pyattacker import StatsServer
with StatsServer("runs/qa.db", port=8787) as server:
print(server.url) # http://127.0.0.1:8787
server.wait() # or just let your program continue
| 端点 | 返回 |
|---|---|
/ |
一个小巧的自动刷新仪表盘 |
/stats, /metrics, /events, /pipelines, /resources, /errors |
JSON |
/stats 带 handoffs_total(所选运行的提交数,run_id=all 则为全部提交),每行 /pipelines 带
handoffs(活动执行记录的计数)、handoffs_historical(全部记录的计数)和
handoff_floor,紧挨着 n_tasks_done/n_tasks_total——开控制流的流水线上,这两者是链中的
位置,不是已跑任务数,所以非零的 handoffs 才说明"这条
流水线跳过了站点"。
没有 artifact 路由,端点也没有认证。 JSON 视图暴露的是这次运行记下来的东西:
/events 把每条事件的 data 原样返回,/pipelines、/errors 以及 /stats(通过
recent_errors)返回存下来的 error_message。任务把某行数据打进日志、或者把它写进
异常信息里抛出来,就等于在那里公开了它——所以请把它当成"看你自己数据的调试视图",
而 artifact 载荷本身只能通过存储或 pyattacker export 拿到。正因如此,它只绑回环地址。把它
暴露到其他任何地方之前,先架你自己的代理。pyattacker serve 就是同一功能的
命令行版本。
pyattacker.monitor.render_snapshot(snapshot) 和 read_snapshot(store) 是 pyattacker watch 背后的
终端渲染器,你想嵌同样的视图就用。从子模块导入它们:
from pyattacker.monitor import render_snapshot, read_snapshot。
接下来读什么¶
| 文档 | 内容 |
|---|---|
docs/tutorial.md |
引导式路径:17 个可运行步骤 |
docs/cli.md |
命令、flag、退出码、配置文件格式 |
docs/design.md |
模型、不变量,以及这些 API 背后的权衡 |
examples/ |
完整程序,包括对多种流水线形态的实测比较 |
Suite API¶
ExperimentSpec(id, factory, definition_digest, pool_aliases={}, label="", config_path="", ignored_run_fields=[], limit=None) 描述一个可重复打开的输入工厂。SuiteSpec(id, experiments, output_root, layout="combined", pools=[], run={}, path="", unresolved_env=[]) 组合成员。使用 suite.runner(on_pipeline_finished=callback, **run_overrides) 和 runner.run(suite.pipelines(runner.store, experiments=None, limit=None)) 执行。SuiteStore(root, read_only=True, experiment=None, max_open=8) 可以打开两种布局,两者都需要这套存储能力。TaskContext 提供 suite_id、experiment_id、local_key、output_dir,PipelineRecord 还保留 repeat。Runner.report_metric 接受 experiment_id 写入成员指标。这里列出的空容器默认值在实现中使用 dataclass 工厂,不共享可变参数。