實體 ID 與關聯
這一頁描述的是 orchestration 的目標設計,不一定等於目前程式碼的實際行為。原文刻意不討論遷移與向後相容。
原則#
Provider id 是證據。App id 才是身分。
每個持久化的實體都使用 app 自有的 id 作為主鍵。Provider 原生的 id 以選用的 ref(參照)形式儲存,只用於關聯(correlation)、除錯、replay 和 command 路由。
ID 家族#
ThreadId app-visible conversation thread
RunId counted app turn
NodeId execution graph node
ProviderSessionId live/resumable provider runtime session
ProviderThreadId app handle for provider-native conversation
ProviderTurnId app handle for provider-native turn
RuntimeRequestId app handle for provider callback/request
RawEventId optional raw provider diagnostic frame id
CheckpointId app checkpoint id
PlanId app plan/todo/question artifact id
Provider id 絕對不會取代這些 id。
Provider ref#
Provider ref 是附在 runtime 實體和 event 上的 metadata。
type ProviderRefs = {
provider: ProviderKind;
nativeSessionRef?: string;
nativeThreadRef?: string;
nativeTurnRef?: string;
nativeItemRef?: string;
nativeRequestRef?: string;
rawEventId?: RawEventId;
method?: string;
};
Codex 可以填入其中大部分的欄位。其他 Provider 可能只填入一部分。
關聯的範圍#
關聯永遠是有範圍(scope)的。絕對不要在沒有 Provider/session/thread 範圍的情況下,對原生 id 做全域比對。
建議使用的比對鍵:
ProviderThread:
provider + providerSessionId + nativeThreadRef
ProviderTurn:
providerThreadId + nativeTurnRef
or providerThreadId + turnOrdinal
Node / TurnItem:
providerTurnId + nativeItemRef
or providerTurnId + itemOrdinal
RuntimeRequest:
providerSessionId + nativeRequestRef
or providerTurnId + requestOrdinal
如果某個 Provider 會重複使用 id,Provider adapter 必須把範圍放寬,直到對應關係不再有歧義為止。
對應關係的儲存#
V2 需要一張持久化的關聯資料表,或是等效的、以 event sourcing 方式保存的 binding 串流。
type IdentityBinding = {
id: IdentityBindingId;
appEntityKind: "provider_thread" | "provider_turn" | "node" | "item" | "request" | "message";
appEntityId: string;
provider: ProviderKind;
providerSessionId: ProviderSessionId;
nativeKind: string | null;
nativeRef: string | null;
scope: {
threadId: ThreadId | null;
runId: RunId | null;
parentNodeId: NodeId | null;
providerThreadId: ProviderThreadId | null;
providerTurnId: ProviderTurnId | null;
};
correlation: "native_exact" | "native_scoped" | "ordinal" | "fingerprint" | "synthetic";
firstRawEventId?: RawEventId;
lastRawEventId?: RawEventId;
createdAt: string;
updatedAt: string;
};
這並不是要做成一個龐大的身分識別子系統。它只是一個最小的持久化位置,用來記錄「這個原生的東西對應到這個 app 的東西」。原始 event 的參照是選用的診斷指標;binding 本身絕對不可以要求正式環境的 SQLite 永久保留原始 Provider frame。
ID 配置規則#
- 如果 Provider 提供了穩定的原生 id,就重複使用既有的 binding,或是配置一個新的 app id 並綁定它。
- 如果 Provider 沒有提供穩定的 id,就依有範圍的序號(scoped ordinal)來配置。
- 如果序號還不夠用,就在範圍內再加上 fingerprint。
- 一旦配置完成,絕對不要更改該實體的 app id。
- 絕對不要用 id 的字串比對來推斷父層/根層是否已完成。
有範圍的序號#
能力較弱的 Provider 需要決定性(deterministic)的序號。
runOrdinalWithinThread
providerTurnOrdinalWithinProviderThread
nodeOrdinalWithinParent
itemOrdinalWithinProviderTurn
requestOrdinalWithinProviderTurn
messageOrdinalWithinNode
序號是 normalizer 在處理 Provider frame 時指派的。對於同一份 Provider transcript,序號在 replay 時必須是決定性的。
Fingerprint#
Fingerprint 只是退而求其次的關聯工具。它們應該包含範圍和穩定的結構,而不是大段會變動的文字。
範例:
ProviderTurn fingerprint:
providerThreadId + runId + providerTurnOrdinalWithinProviderThread
Node / TurnItem fingerprint:
providerTurnId + itemKind + itemOrdinalWithinProviderTurn + toolName?
RuntimeRequest fingerprint:
providerTurnId + requestKind + nativeItemRef? + requestOrdinalWithinProviderTurn
不要把完整的 assistant 文字當成主要的 fingerprint。文字會變動、可能分成多個 chunk 串流進來,也可能重複。
Runtime event#
正規化之後的 runtime event 應該同時帶有 app id 和 Provider ref。
type RuntimeEvent = {
id: RuntimeEventId;
type: RuntimeEventType;
threadId: ThreadId;
runId: RunId | null;
nodeId: NodeId | null;
parentNodeId: NodeId | null;
providerSessionId: ProviderSessionId | null;
providerThreadId: ProviderThreadId | null;
providerTurnId: ProviderTurnId | null;
nativeItemRef: string | null;
requestId: RuntimeRequestId | null;
providerRefs: ProviderRefs;
payload: unknown;
createdAt: string;
};
下游系統應該使用 app id。Provider ref 保留下來是為了檢視和 adapter 路由。
Command 路由#
UI 和 orchestration 的 command 都以 app id 為對象。
approval.respond(RuntimeRequestId)
interrupt.run(RunId)
interrupt.node(NodeId)
fork.fromNode(NodeId)
rollback.toRun(ThreadId, runOrdinal)
Provider command 層在邊界上把 app id 解析成 Provider ref。如果 Provider ref 不存在或已經不再有效(live),command 就會失敗,並帶有一個具型別的能力/狀態錯誤。
範例:
RuntimeRequestId -> nativeRequestRef + providerSessionId
RunId -> root NodeId -> ProviderTurnId -> nativeTurnRef
NodeId -> ProviderThreadId -> nativeThreadRef
重啟之後的待處理 request#
待處理 request 的紀錄可能在重啟之後還留著,但 Provider 的 callback 狀態通常不會。
V2 應該區分「歷史上看起來還在等待的 request」和「真的可以回應的 request」:
type ResponseCapability =
| { type: "live"; providerSessionId: ProviderSessionId }
| { type: "not_resumable"; reason: string };
UI 可以顯示某個 request 已經過期,使用者必須重新啟動或重新執行這個 turn。
Subagent 的關聯#
Subagent 是透過父子關係的執行節點(execution node)來表示的。
以 Codex 來說:
collabAgentToolCall item
-> ExecutionNode(kind="subagent" or "tool_call")
receiverThreadIds[]
-> ProviderThread records
child turn/started and turn/completed
-> child ProviderTurn and child ExecutionNode
子層的 Provider turn 保有它自己的 Provider ref。它是透過 parentNodeId 連結到父層,而不是把它的 turn id 換成父層的 turn id。
對於能力較弱的 Provider,adapter 可以在目前作用中的父節點底下,依序號建立子節點。
Replay 的決定性#
給定相同的 Provider transcript 和相同的 app 初始狀態,正規化必須產生相同的 id。
需求:
- Identity binding 要以 event 或持久化資料列的形式保存下來。
- 只要 binding 在下游 projection 之前就已經持久化,產生出來的 id 可以是隨機的。
- Fixture replay 可以使用決定性的 id 產生方式,讓斷言比較好寫。
- 重新處理同一個 Provider frame 時,應該找到既有的 binding,而不是再配置出另一個實體。
本頁譯自 docs/orchestration-v2/entity-ids-and-correlation.md(英文原文,版本 ce65526)。標示「本站補充」的區塊不在原文裡。