
字节笔记本
2026年10月24日 · 约 21 分钟读完
RunState:跨进程审批的序列化

本文是《从 0 写 Agent》系列第 13 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「RunState:跨进程审批的序列化」。
上一章我们讲了 HITL 的状态机。这一章深入它的"序列化",为什么审批状态要能序列化成 JSON?序列化时指针怎么办?反序列化怎么"按名字找回 Agent"?
这是跨进程审批的技术核心,后端把状态拍平成 JSON 发给前端,前端记录审批,发回,后端重建状态恢复执行。整个过程要求状态能完美 round-trip。
引子:为什么审批要跨进程
第 12 章末尾提过,审批可能发生在另一个进程。最常见的场景是 Web 应用:
Agent 后端(Go 服务) 浏览器前端
│ │
├─ Run 到 delete_user,暂停 │
├─ RunState → JSON │
├─ 通过 WebSocket 发 JSON ───► │
│ ├─ 弹窗"要删用户 123,同意?"
│ ├─ 用户点"同意"
│ ◄──── WS 发回 {approve: true}
├─ 重建 RunState │
├─ ResumeRun │后端不能把 *Agent 指针直接发给前端,指针是内存地址,跨进程无意义。必须序列化成数据(JSON)。前端处理完,发回数据,后端反序列化重建。
这个"序列化→传输→反序列化"的 round-trip,是分布式 HITL 的核心难题。

难题一:指针不能序列化
RunState 里全是 Go 的指针和复杂对象:
type RunState struct {
CurrentAgent *Agent // 指针!
GeneratedItems []RunItem // 接口,背后是各种具体类型
Interruptions []*ToolApprovalItem // 又是指针
Approvals *ApprovalStore // 带 mutex 的对象
// ...
}直接 json.Marshal 这些,*Agent 会被序列化成完整的 Agent 定义(包括工具、指令,一大坨);*ApprovalStore 里的 sync.Mutex 根本不能序列化。
解法,用一个中间结构 serialRunState,把所有指针"拍平"成值(run_state.go:125):
// agents/run_state.go:125
type serialRunState struct {
SchemaVersion string `json:"schema_version"`
CurrentAgent string `json:"current_agent"` // 名字,不是指针
CurrentTurn int `json:"current_turn"`
OriginalInput []json.RawMessage `json:"original_input"` // 原始 JSON
GeneratedItems []serialItem `json:"generated_items"` // 拍平的 item
ModelResponses []serialResponse `json:"model_responses"`
InterruptedResponse *serialResponse `json:"interrupted_response"`
Interruptions []serialInterruption `json:"interruptions"`
Approvals map[string]serialApproval `json:"approvals_by_call_id"`
ApprovalsByTool map[string]serialApproval `json:"approvals_by_tool"`
Usage *Usage `json:"usage"`
}关键,CurrentAgent 存的是 string(agent 名字),不是 *Agent。 ApprovalStore 的 mutex 没了,只存决策的 map。
item 怎么拍平
RunItem 是个接口,背后有 MessageOutputItem、ToolCallItem 等多种类型。序列化时统一转成 serialItem(run_state.go:99):
type serialItem struct {
Type string `json:"type"` // "message" / "tool_call" / ...
Agent string `json:"agent"` // 哪个 agent 产的(名字)
Input json.RawMessage `json:"input"` // 原始的 Responses API input item JSON
}serializeItems(run_state.go:205)调每个 RunItem 的 ToInputItem(),把它转回 Responses API 的 input 格式,再 JSON marshal。 为什么转成 input 格式?因为 resume 时,loop 要从 input items 重建历史,input 格式是 lingua franca(第 2 章讲过)。
难题二:反序列化怎么找回 Agent
序列化时 CurrentAgent 存了名字(比如 "billing_agent")。反序列化时,怎么从 "billing_agent" 变回 *Agent 指针?
答案是 agent 注册表,调用方在 RunStateFromJSON 时传一个 registry map[string]*Agent:
// agents/run_state.go:237
func RunStateFromJSON(data []byte, registry map[string]*Agent) (*RunState, error) {
var in serialRunState
json.Unmarshal(data, &in)
lookup := func(name string) *Agent { return registry[name] }
st := &RunState{
CurrentAgent: lookup(in.CurrentAgent), // 按名字找回指针
// ...
}
if st.CurrentAgent == nil {
return nil, fmt.Errorf("unknown agent %q; add it to the registry", in.CurrentAgent)
}
}调用方要提供 registry,所有可能参与 run 的 Agent,按名字登记:
registry := map[string]*agents.Agent{
"main_agent": mainAgent,
"billing_agent": billingAgent,
"support_agent": supportAgent,
}
state, _ := agents.RunStateFromJSON(jsonBytes, registry)
agents.ResumeRun(ctx, state, opts)如果 registry 里没有 JSON 引用的 agent,直接报错(run_state.go:253)。为什么严格?因为找不到 agent 意味着 resume 会用错误的配置继续,可能调用错误的工具、错误的指令。严格报错优于带着错误状态继续。
为什么不把 Agent 定义也序列化
有人会想,直接把 Agent 的完整定义(工具、指令、模型)序列化进 JSON,不就不用 registry 了吗?
技术上可行,但有几个问题:
- 工具是函数,
OnInvoke func(...)不能序列化(Go 的函数是代码指针) - 体积爆炸,一个 Agent 可能有 10 个工具,每个工具的 schema 几 KB,序列化整个 Agent 几十 KB
- 版本一致性,JSON 里的 Agent 定义可能和当前代码不一致(代码改了但 JSON 没更新)
所以 agents-go 选择"存名字,反序列化时按名字从当前代码找回"。这样:
- JSON 体积小(只存名字)
- 函数不序列化(从当前代码拿)
- 版本一致(用的永远是当前代码的 Agent 定义)
代价,调用方要维护 registry。但这是合理的,你知道你的 Agent 有哪些,按名字登记不难。
难题三:item 重建为什么用 rawInputRunItem
反序列化 item 时,agents-go 用了一个特殊的类型 rawInputRunItem(run_items.go:150):
// agents/run_items.go:150(简化)
type rawInputRunItem struct {
item TResponseInputItem // 直接持有的 input item
agent *Agent
}
func (r *rawInputRunItem) ToInputItem() TResponseInputItem {
return r.item // 直接返回持有的 input item
}为什么要有这个类型? 因为 resume 时,loop 需要的是"历史 input items",不是完整的 RunItem。
回忆序列化,item 被转成 serialItem.Input(JSON),反序列化时从 JSON 解析回 TResponseInputItem,包进 rawInputRunItem。这个包装类型"假装"自己是个 RunItem,但实际上只是 input item 的容器。
为什么这样设计? 因为 loop 在 resume 时,处理 generatedItems 的方式是"把它们转回 input items 再发给模型"。rawInputRunItem.ToInputItem() 直接返回持有的 item,零转换。这是为 resume 优化的数据结构。
难题四:Approvals 的两套 map
注意 serialRunState 有两个 approval map(run_state.go:136-137):
Approvals map[string]serialApproval `json:"approvals_by_call_id"` // 按调用 ID
ApprovalsByTool map[string]serialApproval `json:"approvals_by_tool"` // 按工具名对应 ApprovalStore 的两种决策粒度(第 12 章讲过),
approvals_by_call_id:单次决策("这次 call_abc 同意")approvals_by_tool:永久决策("以后 delete_user 都同意")
序列化时把这两个 map 分别导出(run_state.go:192-199)。反序列化时分别还原。这样 round-trip 后,"单次"和"永久"的决策都能保留。
注意序列化时还处理了 mutex,ApprovalStore 有 sync.Mutex,序列化时要先加锁(run_state.go:192),防止并发写破坏快照一致性:
s.Approvals.mu.Lock()
for k, d := range s.Approvals.byCallID {
out.Approvals[k] = serialApproval{...}
}
s.Approvals.mu.Unlock()序列化是"状态的快照",快照时要锁,保证读到的是一致的状态。
Schema 版本:向前兼容的保险
serialRunState 第一个字段是 SchemaVersion(run_state.go:126):
SchemaVersion string `json:"schema_version"`
const RunStateSchemaVersion = "1.0"反序列化时检查版本(run_state.go:242):
if in.SchemaVersion != RunStateSchemaVersion {
return nil, fmt.Errorf("unsupported schema version %q (want %q)", ...)
}为什么需要版本? 因为序列化格式可能演进。今天 1.0,将来可能加字段、改结构。有了版本号:
- 旧 JSON(1.0)遇到新代码(2.0)→ 报错,提示升级
- 新 JSON(2.0)遇到旧代码(1.0)→ 报错,提示代码太旧
这是数据序列化的标配,永远加版本号,永远检查。 否则格式变了,旧 JSON 反序列化出错误数据,debug 到死。
注释特意说明(run_state.go:14),"guarantees Go↔Go round-trips; not binary-compatible with Python SDK"。agents-go 的 RunState 格式和 Python SDK 不兼容,因为 Go 的 Agent 注册表、RunItem 类型都和 Python 不同。这是合理的,跨语言兼容代价太大。
一个完整的跨进程审批流程
把第 12-13 章串起来,一个完整的 Web 端 HITL 流程:
// === 后端进程 ===
// 1. 第一次 Run,遇到审批中断
result, _ := agents.Run(ctx, agent, "删用户 123", opts)
if result.State != nil {
// 2. 序列化 RunState
stateJSON, _ := json.Marshal(result.State)
// 3. 存到数据库(或发给前端)
db.Save("run_state", runID, stateJSON)
ws.Send(frontend, map[string]any{
"type": "approval_needed",
"interruptions": result.State.Interruptions,
})
}
// === 前端进程(浏览器)===
// 4. 收到审批请求,弹窗,用户点"同意"
// 5. 发回决策
ws.Send(backend, map[string]any{
"type": "approve",
"call_id": "call_abc",
})
// === 后端进程(恢复)===
// 6. 读回 RunState JSON
stateJSON, _ := db.Load("run_state", runID)
// 7. 反序列化(需要 registry)
registry := map[string]*agents.Agent{"main": agent}
state, _ := agents.RunStateFromJSON(stateJSON, registry)
// 8. 记录审批决策
state.Approve(findInterruption(state, "call_abc"), false)
// 9. Resume
result, _ = agents.ResumeRun(ctx, state, opts)
// 继续执行,完成九步,跨两个进程、两次网络传输、一次数据库持久化。 所有这些能跑,全靠 RunState 的序列化设计。
小结
读完这一章,你应该理解 RunState 序列化的四个核心设计:
- 拍平指针,用
serialRunState中间结构,把指针存成名字/ID - agent 注册表,序列化存名字,反序列化时调用方提供 registry 按名字找回
- rawInputRunItem,为 resume 优化的轻量 item 容器
- Schema 版本,
SchemaVersion="1.0",永远检查,防止格式演进导致错误
核心权衡,"存名字不存定义":
- 优点:JSON 小、函数不序列化、版本一致
- 代价:调用方维护 registry、严格检查找不到就报错
HITL 是 Agent 安全的核心,而序列化是分布式 HITL 的核心。 没有可靠的序列化,跨进程审批就做不了,Web 端 HITL 就实现不了。agents-server 的整个 WS 审批流(第 37 章),就建立在这个序列化基础上。
下一篇
第 14 章,Guardrails:输入输出的安全护栏。HITL 管"工具执行前的审批",Guardrails 管"输入输出本身的安全性",拦截恶意 prompt、限制输出长度、检查工具参数。这是 Part II 的最后一章,给 Agent 加上全方位的安全边界。
本文是《从 0 写 Agent》系列第 13 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。



