运行
一次运行是一行记录、一个游标、一份尝试的流水、一组截止时间和一组挂起。这一页讲的是你能对它做什么,以及它回过头来告诉你什么。
启动一次运行
一次运行从一个定义 id 和一份输入开始。如果这个定义带 schema,守护进程会用它 校验输入,然后锁定已存的最高版本,生成这次运行和它的 trace,并把存下来的运行 返回给你。从这一刻起,这次运行就自己往前走:守护进程把它的步骤派给 worker, 走完它的睡眠和等待,并把发生的事写进事件流水。
启动、答复、跟随
// Start a run from anywhere that holds an execute key, answer the wait from a
// webhook, then follow the journal to the end and read the run.
const run = await mcpd.runs.start({
definitionId: 'support_ticket',
input: { ticket: 'Q-42' },
})
await mcpd.events.publish({
event: 'approved',
key: 'Q-42',
payload: { by: 'ops' },
eventId: 'hook-1', // a repeat of this id is a duplicate, not a second event
})
for await (const event of mcpd.runs.watch(run.runId)) {
console.log(event.seq, event.kind)
}
const detail = await mcpd.runs.get(run.runId)
console.log(detail.run.status, detail.run.output) # Start a run from anywhere that holds an execute key, answer the wait from a
# webhook, then follow the journal to the end and read the run.
run = mcpd.runs.start(support, input={"ticket": "Q-42"})
# A repeat of this event id is a duplicate, not a second event.
mcpd.events.publish("approved", "Q-42", payload={"by": "ops"}, event_id="hook-1")
for event in mcpd.runs.watch(run.run_id):
print(event.seq, event.kind)
detail = mcpd.runs.get(run.run_id)
print(detail.run.status, detail.run.output) // Start a run from anywhere that holds an execute key, answer the wait from
// a webhook, then follow the journal to the end and read the run.
let start = StartRun::new("support_ticket").input(json!({ "ticket": "Q-42" }));
let run = mcpd.start_run(start).await?;
// A repeat of this event id is a duplicate, not a second event.
let payload = json!({ "by": "ops" });
let approval = PublishEvent::new("approved", "Q-42").payload(payload).event_id("hook-1");
mcpd.publish_event(approval).await?;
mcpd.watch_run(&run.run_id, -1, |e| println!("{} {:?}", e.seq, e.kind)).await?;
let detail = mcpd.get_run(&run.run_id).await?;
println!("{:?} {:?}", detail.run.status, detail.run.output); // Start a run from anywhere that holds an execute key, answer the wait from
// a webhook, then follow the journal to the end and read the run.
run, err := m.StartRun(ctx, mcpd.StartRun{
Definition: "support_ticket",
Input: map[string]any{"ticket": "Q-42"},
})
if err != nil {
panic(err)
}
// A repeat of this event id is a duplicate, not a second event.
_, err = m.PublishEvent(ctx, mcpd.PublishEvent{
Event: "approved",
Key: "Q-42",
Payload: map[string]any{"by": "ops"},
EventID: "hook-1",
})
if err != nil {
panic(err)
}
_ = m.WatchRun(ctx, run.RunID, -1, func(e mcpd.RunEvent) error {
fmt.Println(e.Seq, e.Kind)
return nil
})
detail, _ := m.GetRun(ctx, run.RunID)
fmt.Println(detail.Run.Status, string(detail.Run.Output)) // Start a run from anywhere that holds an execute key, answer the wait from
// a webhook, then follow the journal to the end and read the run.
val run = mcpd.startRun("support_ticket", input = mapOf("ticket" to "Q-42"))
// A repeat of this event id is a duplicate, not a second event.
mcpd.publishEvent("approved", "Q-42", payload = mapOf("by" to "ops"), eventId = "hook-1")
mcpd.watchRun(run.runId) { event -> println("${event.seq} ${event.kind}") }
val detail = mcpd.getRun(run.runId)
println("${detail.run.status} ${detail.run.output}") # Start a run from anywhere that holds an execute key, answer the wait from a
# webhook, then follow the journal to the end and read the run.
{:ok, run} = Mcpd.start_run(mcpd, support, input: %{"ticket" => "Q-42"})
# A repeat of this event id is a duplicate, not a second event.
{:ok, _} =
Mcpd.publish_event(mcpd, "approved", "Q-42",
payload: %{"by" => "ops"},
event_id: "hook-1"
)
mcpd |> Mcpd.watch_run(run.run_id) |> Enum.each(&IO.inspect({&1.seq, &1.kind}))
{:ok, detail} = Mcpd.get_run(mcpd, run.run_id)
IO.inspect({detail.run.status, detail.run.output})
任何可能被重试的调用方,都应该带上一个 idempotency_key。在这次
运行还被保留着的期间,它在一个项目内是唯一的;同一个调用重试时,得到的是
同一次运行,而不是启动第二次。这样一来,触发了两次的 webhook,或者重启了的
任务执行器,都不会有任何代价。
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.
事件流水
对一次运行的每一次改变,都是一个带序号的事件,序号从零开始,中间不留空档: 运行启动了,游标移动了,某个步骤被租出、开始、完成或失败了,运行等待又恢复 了,运行结束了。每个事件都带一份由它的种类决定形状的载荷。事件流水就是这次 运行的历史,也是界面画出来的那个东西。
你可以用 after 一页一页地读它,也可以带上
Accept: text/event-stream 把事件端点一直开着,每个事件一提交
就收到。一个停下来又重新开始的客户端,把它见过的最后一个序号传上去,就一条
也不会漏。每个 SDK 的 watch 都替你做这件事,并在运行结束时
结束;wait 则一路跟着流水走到底,把输出交回来,或者在运行没有
成功时,交回一个带着这次运行的、有类型的失败。
事件与等待
一个 wait_event 步骤会把运行挂在那里,直到一个名字和渲染出来的
键都对得上的事件到来。事件只从一道门进来:
答复一次等待
curl -X POST http://localhost:4317/v1/api/events \
-H 'content-type: application/json' \
-d '{"project":"support","event":"approved","key":"Q-42","payload":{"by":"ops"},"event_id":"hook-1"}'
答复是三种结果之一。delivered:有一次运行正在等待,并在同一个
事务里收下了这个事件,所以等你读到回执时,运行已经往前走了。
parked:没有运行在等待,这个事件被留住,交给之后才打开、并且
声明接受留住事件的等待。duplicate:这个 event_id
之前见过,什么也没有写入。任何可能触发两次的东西,都请自己带上 id。
一个到了截止时间的等待,按它的 on_timeout 说的做:失败,然后
由它的 on_error 来决定;或者不带输出地继续往下走。要直接发给
某一次运行,走它自己打开的那个等待,用
POST /v1/api/runs/{run}/signal;它接收同样的请求体,但不带
键,因为这次运行本身就是地址。
取消
POST /v1/api/runs/{run}/cancel 结束一次运行,以及它下面的
每一个子运行。正在执行中的步骤不会在网络层上被打断:worker 要到下一次心跳
才知道运行已经结束,它的上下文会通知步骤主体,而步骤主体之后再提交的任何东西,
都会以 run_terminal 被拒绝。取消一次已经结束的运行不改变任何
东西,只是把这次运行照原样返回。
定时器与上限
睡眠、等待的截止时间、重试的退避和租约到期,全都是守护进程持有的定时器。
它们按守护进程的时钟触发,一秒一次,所以一百毫秒的
retry_after_ms 会被当作至少这么久,而不是恰好这么久。守护进程
重启不会丢掉其中任何一个;它们都是数据行,运行从那一行继续。任何时长都不能
超过 3650 天;worker 送来的更长的 retry_after_ms 会以 400 被
拒绝。晚于 2262 年的 sleep_until 会被当作永远不会到期。
一个定义的 max_steps、run_timeout_ms 和
max_cost_usd 会在触到上限时结束这次运行,并把这个上限写成错误。
成本是各个步骤上报的用量所定出的价钱之和,用的是给 span 定价的同一套价目表,
所以一个上报了自己 token 数的智能体步骤,一完成就会记到这次运行的预算上。
运行在它的 trace 里
每一次运行同时也是一条 trace。守护进程为这次运行开一个根 span,为每一次
尝试再开一个 span,带上这个步骤的输入和输出;如果步骤主体通过步骤上下文记录了
自己的 span,那些 span 会挂在它下面。于是一个智能体步骤里的模型调用,就位于
那个步骤之下、那次运行之下;而适用于任何 trace 的搜索、成本核算和评分器,
原封不动地适用于一次运行的 trace。这些 span 走的是 SDK 的 span 所走的同一条
摄取路径,所以它们以同样的方式被采样、脱敏、定价和转发。token 记在花掉它们的
那些模型 span 上;
步骤 span 在自己的属性里带上定过价的总和,所以一条 trace 的合计对每个 token
只数一次。运行带着它的 trace_id,界面把两者链在一起。
守护进程按自己的时钟,通过一个 outbox 写出这些 span,时机在产生它们的那一行 提交之后。某个步骤刚完成的那一刻读到的 trace,可能还没有带上这个步骤的 span;而这次运行自己的详情永远有。
把运行读回来
GET /v1/api/runs 列出一个项目的运行,最近更新的排在最前,可以
按状态或定义过滤。GET /v1/api/runs/{run}
是这次运行,以及关于它的一切持久内容:每一次尝试及其租约持有者、各个时间、
错误和用量;仍然待触发的定时器;仍然开着的等待。界面的
工作流屏
就是把这个端点画出来,旁边配着实时的事件流水。
这两个端点都会交回这次运行收到的东西,所以两者都会记进审计日志,分别记作
list_runs 和 get_run;事件流水
(list_run_events,每个请求记一次,流式请求也包括在内)、原因
回溯(get_step_cause)和取消(cancel_run)也一样。
一次被拒绝的读取,记在拥有这次运行的项目名下。worker 的调用不记录;见
Worker。
存储
运行存放在 trace 存储旁边、属于它们自己的 SQLite 文件里,路径是
<data>.runs.<ext>,除非 --runs-data
另有指定;而 --runs-data off 会把整个运行接口关掉,此时每一个
运行端点都返回 503。请把它和 trace 存储一起备份;mcpd doctor
会把它当作独立的一组来评分。
运行存储按载荷发来时的原样保存它们。一次运行的输入、状态、输出和关联信息,
每个步骤的输入、输出和错误,每个事件的载荷,以及还在等待写进 trace 存储的
那些 span 载荷,都不经脱敏地存下;一次运行的关联信息里的 user_id
也不做哈希。这是有意的:它们是工作流的工作状态,后面的步骤会把它们读回去,
而一个被脱敏器改写过的值会改变这次运行的行为。守护进程写进 trace 存储的
span,经过的脱敏和标识符哈希与 SDK 发来的 span 相同,所以 trace 存储里存的
是脱敏后的副本。请把运行文件当作持有你的步骤收到过的一切,并据此保护和备份它。
保留期也作用于运行存储。修剪 trace 存储的那次每小时的清理,也会按同一个 按项目的窗口,删除根运行在截止点之前结束的每一次运行,连同它的子运行,以及 为它们存下的一切:尝试、事件、截止时间、等待、排队中的 span,以及这些运行 消费掉的事件。一次还没有结束的运行永远不会被删除,不管它有多旧。定义会被 保留。
擦除也作用于它。DELETE /v1/api/subjects/{user_id} 会删除
这个项目里每一次已经结束、并且关联信息带着那个 user_id 的运行,
按你启动这次运行时给的原样匹配,连同它整棵子运行树以及为它们存下的一切,并把
数量报告为 runs_erased。这个人的一次还没有结束的运行会被完整地
留下,计入 runs_in_flight,并在回复的 note 里写明:
取消它,或者等它结束,然后再发一次擦除请求。为一次还没走到它的等待的运行留住
的事件,是按它的关联键、而不是按某个人来索引的,所以擦除够不到它;它会在过期
时被删除,也就是 24 小时之后。回复的其余部分见
脱敏与保留期。
接下来
- 一个定义能说些什么: 工作流。
- 把步骤真正跑起来的那个进程: Worker。
- 每一个运行端点,都由规范生成: HTTP API,Runs。