ByteNoteByteNote
RunState:跨进程审批的序列化
字

字节笔记本

2026年10月24日 · 约 21 分钟读完

RunState:跨进程审批的序列化

API中转
¥120

CLAUDE.md 与自动记忆:Agent 的项目记忆

本文是《从 0 写 Agent》系列第 13 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「RunState:跨进程审批的序列化」。

上一章我们讲了 HITL 的状态机。这一章深入它的"序列化",为什么审批状态要能序列化成 JSON?序列化时指针怎么办?反序列化怎么"按名字找回 Agent"?

这是跨进程审批的技术核心,后端把状态拍平成 JSON 发给前端,前端记录审批,发回,后端重建状态恢复执行。整个过程要求状态能完美 round-trip。


引子:为什么审批要跨进程

第 12 章末尾提过,审批可能发生在另一个进程。最常见的场景是 Web 应用:

text
Agent 后端(Go 服务)           浏览器前端
    │                              │
    ├─ Run 到 delete_user,暂停     │
    ├─ RunState → JSON             │
    ├─ 通过 WebSocket 发 JSON ───► │
    │                              ├─ 弹窗"要删用户 123,同意?"
    │                              ├─ 用户点"同意"
    │   ◄──── WS 发回 {approve: true}
    ├─ 重建 RunState               │
    ├─ ResumeRun                   │

后端不能把 *Agent 指针直接发给前端,指针是内存地址,跨进程无意义。必须序列化成数据(JSON)。前端处理完,发回数据,后端反序列化重建。

这个"序列化→传输→反序列化"的 round-trip,是分布式 HITL 的核心难题。


Hooks:运行时事件钩子参考

难题一:指针不能序列化

RunState 里全是 Go 的指针和复杂对象:

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):

go
// 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):

go
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:

go
// 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,按名字登记:

go
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 了吗?

技术上可行,但有几个问题:

  1. 工具是函数,OnInvoke func(...) 不能序列化(Go 的函数是代码指针)
  2. 体积爆炸,一个 Agent 可能有 10 个工具,每个工具的 schema 几 KB,序列化整个 Agent 几十 KB
  3. 版本一致性,JSON 里的 Agent 定义可能和当前代码不一致(代码改了但 JSON 没更新)

所以 agents-go 选择"存名字,反序列化时按名字从当前代码找回"。这样:

  • JSON 体积小(只存名字)
  • 函数不序列化(从当前代码拿)
  • 版本一致(用的永远是当前代码的 Agent 定义)

代价,调用方要维护 registry。但这是合理的,你知道你的 Agent 有哪些,按名字登记不难。


难题三:item 重建为什么用 rawInputRunItem

反序列化 item 时,agents-go 用了一个特殊的类型 rawInputRunItem(run_items.go:150):

go
// 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):

go
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),防止并发写破坏快照一致性:

go
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):

go
SchemaVersion string `json:"schema_version"`
const RunStateSchemaVersion = "1.0"

反序列化时检查版本(run_state.go:242):

go
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 流程:

go
// === 后端进程 ===

// 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 序列化的四个核心设计:

  1. 拍平指针,用 serialRunState 中间结构,把指针存成名字/ID
  2. agent 注册表,序列化存名字,反序列化时调用方提供 registry 按名字找回
  3. rawInputRunItem,为 resume 优化的轻量 item 容器
  4. 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 移植版)。全系列目录见总览。

相关文章

分享: