工作流
工作流是守护进程持有的数据,加上守护进程永远看不到的步骤主体。这一页讲的是数据:一份定义能说什么,以及每种构造让守护进程做什么。
分工
一次持久化运行有两个主人。步骤与步骤之间,运行归守护进程:游标、事件流水、 重试预算、定时器和等待。步骤里面的一切归你的进程:你调用的模型、你发出的 提示词、你运行的工具、你持有的凭据。这些东西一样都不会交给守护进程,它也 从来不代你去跑一个模型。
所以一个工作流从同一个文件里分成两部分出来。图作为一份定义送到守护进程, 那是一份守护进程按着走的文档。步骤主体留在你的进程里,用你写它时用的那种语言, 等一个 worker 来服务它。 没有任何东西需要部署到守护进程上,而且一个步骤主体可以改,定义完全不用动。
The cursor
The daemon holds the run between steps: where it is, what each step answered, what it is waiting for and until when. A worker holds one step at a time, and only while it runs. During a wait no process is alive at all.
写一个工作流
构建器同时产出这两部分。document() 就是那份定义,而每一次
step 或 agent 调用都会把自己的步骤主体留下来给
worker。每个 SDK 都有同一个构建器和同样的九种构造,所以一个工作流用一种
语言写出来,换一种语言读起来是一样的;不管是哪一种写的,守护进程存下来的
都是同一份文档。
一个客服工作流
const support = workflow({ id: 'support_ticket' })
.step('classify', async (ctx, input: { ticket: string }) => ({
kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
}))
.agent(
'draft',
async (ctx, input: { kind: string }) => {
// Your own model call, recorded under this step in the run's trace. Its
// usage adds up into the step's, which the daemon prices.
return await ctx.withSpan('model_call', async (span) => {
span.setModel('claude-sonnet-4', 'anthropic')
span.setUsage({ inputTokens: 812, outputTokens: 37 })
return `an answer to a ${input.kind}`
})
},
{ input: { kind: '${steps.classify.output.kind}' } },
)
.waitFor('approval', {
event: 'approved',
key: '${input.ticket}',
timeoutMs: 7 * 24 * 3_600_000,
when: when('steps.classify.output.kind', 'eq', 'bug'),
})
.step(
'send',
async (ctx, input: { text: string }) => {
if (!(await deliver(input.text, ctx.idempotencyKey))) {
throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
}
return { sent: true }
},
{
input: { text: '${steps.draft.output}' },
effect: 'at_most_once',
retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
},
)
.output({ sent: '${steps.send.output.sent}' }) support = workflow("support_ticket")
@support.step("classify")
def classify(ctx, input, prior):
return {"kind": "question" if input["ticket"].startswith("Q") else "bug"}
@support.agent("draft", input={"kind": "${steps.classify.output.kind}"})
def draft(ctx, input, prior):
# Your own model call, recorded under this step in the run's trace. What
# the span spends is the step's usage: the daemon prices it from here.
with ctx.span("model_call", kind="model_generation") as span:
span.set_model("claude-sonnet-4", "anthropic")
span.set_usage(input_tokens=812, output_tokens=37)
text = f"an answer to a {input['kind']}"
return text
support.wait_for("approval", event="approved", key="${input.ticket}",
timeout_ms=7 * 24 * 3_600_000,
when=when("steps.classify.output.kind", "eq", "bug"))
@support.step("send", input={"text": "${steps.draft.output}"}, effect="at_most_once",
retry=RetryPolicy(max_attempts=3, backoff="exponential", initial_ms=500))
def send(ctx, input, prior):
if not deliver(input["text"], ctx.idempotency_key):
raise StepFailure("mail relay busy", class_="retryable", retry_after_ms=2_000)
return {"sent": True}
support.output({"sent": "${steps.send.output.sent}"}) let support = Workflow::new("support_ticket")
.step(
"classify",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let ticket = input["ticket"].as_str().unwrap_or("");
let kind = if ticket.starts_with('Q') { "question" } else { "bug" };
Ok::<_, StepFailure>(json!({ "kind": kind }))
},
StepOptions::new(),
)
.agent(
"draft",
|ctx: StepContext| async move {
// Your own model call, recorded under this step in the run's trace.
// Its usage is the step's usage: the daemon prices what the span spent.
let span = ctx.span("model_call", SpanKind::ModelGeneration);
span.set_model("claude-sonnet-4", "anthropic");
span.set_tokens(812, 37);
span.end();
Ok::<_, StepFailure>("an answer")
},
StepOptions::new().input(draft_input),
)
.wait_for(
"approval",
"approved",
"${input.ticket}",
Duration::from_secs(7 * 24 * 3600),
StepOptions::new().when(when("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let text = input["text"].as_str().unwrap_or("");
deliver(text, ctx.idempotency_key()).await.map_err(busy)?;
Ok::<_, StepFailure>(json!({ "sent": true }))
},
StepOptions::new()
.input(json!({ "text": "${steps.draft.output}" }))
.at_most_once()
.retry(backoff),
)
.output(json!({ "sent": "${steps.send.output.sent}" })); support := mcpd.NewWorkflow("support_ticket").
Step("classify", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
var in struct {
Ticket string `json:"ticket"`
}
if err := step.Input(&in); err != nil {
return nil, err
}
kind := "bug"
if strings.HasPrefix(in.Ticket, "Q") {
kind = "question"
}
return map[string]any{"kind": kind}, nil
}).
Agent("draft", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
// Your own model call. A span started from ctx lands under this step,
// and what it spent is the step's usage: the daemon prices the sum.
_, span := m.Start(ctx, "model_call", mcpd.WithModel("claude-sonnet-4", "anthropic"))
defer span.End()
span.SetTokens(812, 37)
return "an answer", nil
}, mcpd.Input(map[string]any{"kind": "${steps.classify.output.kind}"})).
WaitFor("approval", "approved", "${input.ticket}", 7*24*time.Hour,
mcpd.Guard(mcpd.When("steps.classify.output.kind", "eq", "bug"))).
Step("send", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
if err := deliver("the draft", step.IdempotencyKey); err != nil {
return nil, mcpd.Retryable(err, 2*time.Second)
}
return map[string]any{"sent": true}, nil
}, mcpd.Input(map[string]any{"text": "${steps.draft.output}"}), mcpd.AtMostOnce(),
mcpd.Retry(mcpd.RetryPolicy{
MaxAttempts: 3, Backoff: mcpd.BackoffExponential, Initial: 500 * time.Millisecond,
})).
Output(map[string]any{"sent": "${steps.send.output.sent}"}) val support = Workflow("support_ticket")
.step("classify") { ctx ->
val ticket = ctx.input<JsonObject>()["ticket"]!!.jsonPrimitive.content
mapOf("kind" to if (ticket.startsWith("Q")) "question" else "bug")
}
.agent("draft", StepOptions(input = mapOf("kind" to "\${steps.classify.output.kind}"))) {
ctx ->
// Your own model call, recorded under this step in the run's trace.
// Its usage is summed into the step, so the daemon prices the run.
ctx.span("model_call", SpanKind.MODEL_GENERATION) { span ->
span.setModel("claude-sonnet-4", "anthropic")
span.setUsage(inputTokens = 812, outputTokens = 37)
}
"an answer to a " + ctx.input<JsonObject>()["kind"]!!.jsonPrimitive.content
}
.waitFor(
"approval", "approved", "\${input.ticket}", 7.days,
StepOptions(guard = predicate("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
StepOptions(
input = mapOf("text" to "\${steps.draft.output}"),
effect = Effect.AT_MOST_ONCE,
retry = RetryPolicy(
maxAttempts = 3, backoff = Backoff.EXPONENTIAL, initial = 500.milliseconds,
),
),
) { ctx ->
val text = ctx.input<JsonObject>()["text"]!!.jsonPrimitive.content
if (!deliver(text, ctx.idempotencyKey)) {
throw RetryableException("mail relay busy", retryAfter = 2.seconds)
}
mapOf("sent" to true)
}
.output(mapOf("sent" to "\${steps.send.output.sent}")) support =
Workflow.new("support_ticket")
|> Workflow.step("classify", fn _ctx, input, _prior ->
kind = if String.starts_with?(input["ticket"], "Q"), do: "question", else: "bug"
%{"kind" => kind}
end)
|> Workflow.agent(
"draft",
fn ctx, input, _prior ->
# Your own model call, recorded under this step in the run's trace. Its
# usage is the step's: the daemon prices what the trace shows.
opts = [kind: :model_generation, model: "claude-sonnet-4", provider: "anthropic",
usage: %{"input_tokens" => 812, "output_tokens" => 37}]
StepContext.span(ctx, "model_call", opts, fn -> :ok end)
"an answer to a #{input["kind"]}"
end,
input: %{"kind" => "${steps.classify.output.kind}"}
)
|> Workflow.wait_for("approval",
event: "approved",
key: "${input.ticket}",
timeout_ms: 7 * 24 * 3_600_000,
when: where("steps.classify.output.kind", :eq, "bug")
)
|> Workflow.step(
"send",
fn ctx, input, _prior ->
unless deliver.(input["text"], ctx.idempotency_key) do
raise Mcpd.StepException,
message: "mail relay busy", class: :retryable, retry_after_ms: 2_000
end
%{"sent" => true}
end,
input: %{"text" => "${steps.draft.output}"},
effect: :at_most_once,
retry: [max_attempts: 3, backoff: :exponential, initial_ms: 500]
)
|> Workflow.output(%{"sent" => "${steps.send.output.sent}"})
那个例子里有三件事,是每一个工作流的形状。步骤的输入是一份模板,守护进程
为每一次尝试渲染它,所以 draft 读得到 classify
答了什么,而两个步骤主体谁也不知道对方的存在。守卫决定一个节点到底跑不跑:
问题原样发出去,缺陷则等一个人来看。最后一个步骤是带重试策略的
at_most_once,这正是一个副作用需要的组合:守护进程会重试一次
瞬时失败,而且在没有被明确告知的情况下,绝不会把一个步骤主体跑两次。
那些构造
一共九种,每个 SDK 九种都有。其中两种是可领取的,也就是由 worker 来服务; 其余的都是守护进程自己的。
| 构造 | 谁来跑 | 它做什么 |
|---|---|---|
task | worker | 可领取的活。worker 运行它在这个 id 下注册的那段代码。 |
agent | worker | 在网络上是同一回事,但记录成一个智能体回合:你自己的循环,不管里面调用了多少次模型,都算作一个持久化步骤。trace 和界面能把它和普通的 task 区分开。 |
sleep | 守护进程 | 延迟这么多毫秒。这期间没有任何进程活着。 |
sleep_until | 守护进程 | 延迟到某个由模板渲染出来的时刻,比如输入里带着的一个账单日期。 |
wait_event | 守护进程 | 暂停,直到一个名字和键都对得上的事件到来,或者直到截止时间过去。键是每次运行各自渲染的,所以一次等待只对得上一个订单,不会对上别的。 |
parallel | 两者都有 | 带名字的分支,每个分支是一个可领取的步骤;在其中任何一个被派出去之前,全部已经落盘。最后一个分支结束时,运行才往下走。 |
foreach | 两者都有 | 对某条路径上的数组,每个元素一个可领取的步骤,受 max_items 限制;你指明的话,也受同时运行几个的限制。输出按元素顺序作为一个数组返回。 |
loop | 两者都有 | 在一个可领取的步骤上做有上限的循环。until 在每一轮之后求值,可以让它提前结束。 |
subworkflow | 守护进程 | 另一份定义的一次子运行,有自己的事件流水。它的输出就是这个步骤的输出,取消父运行也会取消它。 |
一个分支、一个 foreach 的主体和一个 loop 的主体,各自都是一个可领取的步骤。 需要好几个步骤的主体应该做成子工作流,这样每一份事件流水都是平的:一次运行 是一个列表,永远不是一棵由列表组成的树。
模板与守卫
模板就是 JSON,只是它的字符串里带着 $${path} 这样的引用。
守护进程在创建一次尝试时渲染它,取的是那一刻这次运行手上的东西:
input,这次运行的输入;steps.<id>,某个更早
的步骤答了什么,下面有 .output、.status 和
.error;run,这次运行自己的 id 和关联信息;以及在
foreach 或 loop 内部的 item、index 和
iter.index。如果一个引用就是整个字符串,它会保住自己的类型,
所以 "$${steps.classify.output}" 交给下一个步骤的是一个
对象,而不是一个写成字符串的对象。
守卫是在同样这些路径上的谓词:eq、ne、
lt、lte、gt、gte、
in、exists 和 matches,可以用
all、any 和 not 组合起来。一个为假的
守卫会把这个节点写成 skipped,运行接着往下走。一条不存在的
路径会让每一个比较都为假,而不是抛出错误,因为一次因为少一个字段就死掉的
运行,是没有人能把它跑完的运行。
文档存下来的时候,守护进程会校验它。守卫指向了一条谁也产生不出来的路径,
模板读了一个在后面才声明的步骤,foreach 遍历的东西不是一条路径,id 不匹配
^[a-z][a-z0-9_]*$:每一种都会以 400 被拒绝,并指出是哪个
字段。未知字段同样会被拒绝,所以一个拼错的守卫是一个错误,而不是一次跳过了
自己守卫的运行。
每一个时长都有下限,也有上限。run_timeout_ms、
step_timeout_ms、重试的 initial_ms 和
max_ms、睡眠的 ms,以及等待的
timeout_ms,每一个最多是 315360000000,也就是
3650 天;更长的会以 400 被拒绝,并指出是哪个字段、上限是多少,以及你发来的
值。比这更远的截止时间,放不进守护进程存它所用的那个时钟。
重试、副作用与失败
失败的步骤主体会用一个类别告诉守护进程它是怎么失败的。retryable
表示值得再试一次,只要这个节点的 retry 预算还有,守护进程就会
按这个节点的退避再试一次,并且至少等够步骤主体要求的
retry_after_ms,而它和其他每一个时长一样,受同一个 3650 天
上限的约束。fatal 和
invalid_input 不重试。unservable 表示这个 worker
并不持有这份定义或这个步骤,于是这次尝试回到队列里等另一个 worker,而且不
消耗预算。抛出其他任何东西的步骤主体,都算 fatal。
max_attempts 把每一次尝试都算上,包括第一次,所以默认值 1
意味着不重试。预算用完之后由 on_error 决定:默认的
fail_run 结束这次运行,并把这个步骤的消息当作运行的错误;
continue 把这个步骤标记为失败然后往下走,这样后面的守卫可以读
steps.<id>.status 并绕开它。
effect 说明一个步骤主体被重复执行会付出什么代价。在默认的
at_least_once 下,一份丢失的租约会让这次尝试重新入队,步骤主体
可能再跑一次。在 at_most_once 下,worker 必须在步骤主体之前发出
started,而一次已经 started、租约又丢了的尝试会判为失败,不会
再跑一次。给那个发邮件或者扣款的步骤用它,并把租约的
idempotency_key 交给那个步骤主体,让它转交给第三方:这个键在
同一个步骤的多次重试之间是稳定的,所以一次因为响应丢失而发生的重试,在
对面也能被认出来。
版本
一份定义一旦存下来就是不可变的。再提交一次同样的内容,你拿回的是同一个 版本;在一个已经存在的版本号下提交不同的内容,守护进程会以 409 拒绝,因为 否则一次锁定了那个版本的运行会在脚下被换掉。不写版本号,守护进程就分配下 一个数字,worker 启动时就是这么做的。一次运行会锁定它启动时的那个版本, 再也不重新解析,所以一次存入版本 3 的发布,会让版本 2 上的那些运行按它们 当初写的样子跑完。
上限
一份定义可以声明 max_steps、run_timeout_ms 和
max_cost_usd。守护进程把每一次尝试算在第一个上,把运行的时长
算在第二个上,把各个步骤上报并定过价的用量算在第三个上;触到上限时,它把
运行结束为 timed_out 或 failed,并把这个上限写成
错误。一个收敛不了的循环,和一个花钱停不下来的智能体步骤,在守护进程看来
是同一个问题,同一个设置把两者都拦住。
守护进程保留什么
一次运行的输入、它的状态、它的输出,以及每个步骤的输入和输出,都按发来时的 原样保存在运行存储里。它们不经脱敏:它们是工作流自己的工作状态,后面的步骤 会把它们读回去,而一个被脱敏器改写过的值会改变这次运行的行为。一次运行写进 trace 存储的 span,和其他任何 span 一样会被脱敏。保留期和擦除也作用于运行 存储,针对的是已经结束的运行。 运行那一页确切地写明 了每一种会删除什么。
接下来
- 服务那些步骤主体,以及 worker 讲的那套协议: Worker。
- 启动一次运行,观察它、给它发信号、把它取消: 运行。
- 文档本身,逐个字段: definition.schema.json, 以及 run-contract-v1.md 里那份有约束力的契约。