← blog AI Agent · 2026-09-21

Herdr 多 Agent 实时协同架构拆解:server 拥有终端,agent 按需读取

从『为什么主 pane 的 agent 能实时看到其他 pane 的输出』出发,拆解 Herdr 的 server 持有 PTY + 服务端终端仿真 + JSON API 架构,对比 Claude Code subagent 的进程内结构化派发,分析两种方案在上下文隔离与压缩上的取舍,以及 agent skill 里的读取纪律如何在架构缺口上打补丁。

14 min read

从一个现象开始

Herdr 是一个给 coding agent 用的终端 runtime——README 里那句 slogan 很准确:“the runtime your coding agents live on”。它自己不包装、不替代任何 agent,而是拥有它们的终端:Claude Code、Codex、Cursor CLI、OpenCode 等都原样跑在 Herdr 的 pane 里,由一个后台 server 统一托管。

于是一个有意思的现象出现了:主 pane 里的 agent(比如一个 Claude Code 实例)把任务派发给其他 pane 的 agent 之后,它能”实时”看到对方窗口的输出,等对方干完了再收割结论。多个 agent 就这样在一个终端里协同工作。

直觉答案会是”Herdr 把输出转发给了主 pane”。这个答案是错的。本文拆开这个机制,并顺着往下挖一层:这种协同方式和 Claude Code 原生的 subagent 派发,在技术实现上到底差在哪,各自的上下文管理代价是什么。

以下代码事实基于 2026-09 的 herdr master 分支(commit 3f2a6e7),文中的文件与函数名都可以在 github.com/herdrdev/herdr 对上。

一、核心机制:状态外置的终端仿真

“实时可见”靠的不是转发,而是四个环环相扣的架构事实。

1. 所有 PTY 都由 server 持有

每个 pane 里的进程,其 stdin/stdout 接在 server 创建的 PTY 上,而不是 TUI 客户端,更不是主 pane。输出第一站永远是 server。这就是为什么你关掉客户端、断开 SSH,pane 里的 agent 还在继续跑——README 明确把这个作为首要特性:“herdr keeps terminals running in a background server when you close the client”。

2. server 对每个 pane 逐字节做终端仿真

PTY 读循环的回调在 src/pane.rs 里,每次读到的字节都交给 terminal.process_pty_bytes(...),由 vendored 的 libghostty-vt 终端核心在 server 内存里维护每个 pane 的完整终端状态:屏幕缓冲、scrollback、主/备屏。

关键在于隐藏 pane 也在持续解析。这是 Herdr 项目规则里明确写的:“Hidden panes still parse output”。不管 pane 是否可见、是否被聚焦,server 任何时刻都持有它最新的”屏幕内容”,这份状态不依赖有没有人在看。

3. 内容进入某个 agent 的 context 只有一条路:显式拉取

主 pane 里的 agent 执行 herdr agent read <pane> 或 herdr pane read <pane> --source recent-unwrapped,CLI 通过会话 socket 发 JSON API 请求,server 端的 handle_pane_read(src/app/api/panes.rs)直接从目标 pane 的终端状态取快照(read_terminal_snapshot,src/app/api_helpers.rs):

# 主 pane 里的 agent 这样收割子 agent 的结论
herdr agent get reviewer                                  # 小的结构化状态
herdr agent read reviewer --source recent-unwrapped --lines 120   # 一次有界读取

四个读源各有用途:visible 是当前视口,recent/recent-unwrapped 是回滚区尾部(后者把软折行拼合,适合日志和转录),detection 是检测引擎用的 bottom buffer 快照。读取是有界的:--lines 对 recent 源默认 80 行、硬上限 1000 行。

4. “实时”来自服务端阻塞等待 + 事件信号

同步不需要 agent 自己轮询:

  • herdr agent wait / agent prompt --wait:阻塞发生在 server 侧,等内部 EventHub(一个 512 条容量的带序号环形缓冲)推送 agent 状态事件,状态一变立刻返回一个很小的状态 JSON。
  • herdr pane wait-output --match/--regex:server 侧以 100ms 间隔重读快照做匹配,命中即返回,而且只返回匹配到的那一行。

值得强调的是事件系统的克制:我核对过 API schema 里全部事件类型,pane.output_changed 只携带 pane ID 和 revision 号,pane.agent_status_changed 只带一个状态枚举——没有任何事件携带屏幕文本。server 是状态仓库,不是内容路由器:它推送信号,内容必须显式拉取。

把整条链画出来:

graph LR
    P["子 agent 进程<br/>(claude code / codex / 任意 CLI)"] -->|stdout 字节流| PTY["子 pane 的 PTY<br/>(server 拥有)"]
    PTY -->|逐字节终端仿真<br/>隐藏 pane 也持续解析| TS["该 pane 的终端状态<br/>屏幕 + scrollback<br/>(server 内存,不是 token)"]
    TS -->|渲染| TUI["TUI 客户端<br/>(人旁听,随时介入)"]
    TS -->|"事件流:只推信号<br/>pane_id + 状态枚举,无内容"| EV["EventHub<br/>(512 条环形缓冲)"]
    TS -->|"agent read / pane read<br/>显式拉取,有界(默认 80 行,上限 1000)"| O["主 pane 的<br/>orchestrator agent"]
    EV -.->|"状态事件唤醒<br/>(agent wait 在此阻塞)"| O

这就是对开篇问题的准确回答:每个 pane 的输出天然汇聚成 server 内存里的实时终端状态,主 pane 的 agent 只是通过同一套 JSON API 按需读这份状态。TUI、CLI、远程客户端都是平等的客户端——这也正是 Herdr 自己的架构守则:“pane 的终端内容、agent 状态是 server 拥有的 runtime 事实,TUI 只是其中一个客户端”。

二、对比:Claude Code 的 subagent 派发

Claude Code 的 subagent(见官方文档)是另一种完全不同的实现。一句话概括差异:

Claude Code 的 subagent 是同进程内的”结构化调用”——父 agent 拿到的是结论;Herdr 是跨进程的”终端表面协调”——父 agent 观察的是屏幕,像人一样。

两种拓扑放在一起看最直观:

graph TB
    subgraph CC["Claude Code subagent:进程内结构化调用"]
        direction TB
        PA["父 agent 循环<br/>(同一 harness 进程)"] -->|"Agent 工具调用<br/>fork 型会继承父的全部历史"| SA["子 agent 循环<br/>(独立 context window)"]
        SA -->|"最终报告 = 返回值<br/>(唯一的回流通道)"| PA
        SA -.- MID["中间工具输出<br/>留在子 context 内<br/>对父不可见(harness 强制)"]
    end
    subgraph HD["Herdr pane 派发:跨进程终端表面协调"]
        direction TB
        O2["orchestrator<br/>(pane A 的进程)"] -->|"agent prompt<br/>子 agent 收到的全部输入<br/>只有这一条 prompt"| S2["herdr server<br/>(外置终端状态)"]
        S2 -->|写入 PTY 输入| A2["子 agent CLI<br/>(pane B 的进程)"]
        A2 -->|stdout → server 终端仿真| S2
    end
    O2 ==>|"agent read(有界拉取)<br/>agent wait(状态事件)"| S2
维度Claude Code subagentHerdr pane 派发
被派发者是什么同一进程里的另一个 LLM 对话循环(独立 context window)真实 OS 进程,跑在真实 PTY 里的完整 agent CLI
通信边界类型化的工具返回值:最终报告即函数返回值终端屏幕:agent prompt 发文本,agent read 读屏幕
中间过程可见性刻意对父 agent 不可见(这是特性:文件转储不进父 context)持续可见,任何时刻可读快照、wait 事件
状态放在哪会话内存,随会话消失server 外置的终端状态,客户端断开也存活
谁能观察只有 harness(人只能看 spinner 和最终通知)人、TUI、CLI、远程客户端都是平等观察者
被派发者异构性只能是同种 agent(同一 harness 的模型循环)任何吃终端的程序:Claude Code、Codex、随便什么 CLI
同步模型完成时推送一条通知拉式查询 + EventHub 状态事件

三个最值得展开的点:

语义边界 vs 通用表面。 Claude Code 的 subagent 返回结构化文本,父 agent 零解析成本拿到结论——但前提是双方都是同一 harness。Herdr 的接口是终端屏幕,这个接口”没有类型”,所以它需要一整个检测引擎(每个 agent 一份 TOML manifest,把屏幕状态分类成 idle/working/blocked/done/unknown)去解释屏幕。代价是解释层,收益是通用性——任何 agent CLI 不用改一行代码就能被编排。配合集成 hook 让 agent CLI 主动上报状态,这条路还能逐步从”看屏幕猜”升级为”听 agent 说”。

中间状态外置 vs 内化。 Claude Code subagent 的中间输出留在子 context 里,本质是 context window 管理术;Herdr 把每个 pane 的”此刻屏幕”外置成 server 里的共享事实。这带来一个 Claude Code 做不到的性质:人可以随时介入某个被派发的 pane——看它在干嘛、手动回答它的 approval 弹窗,然后 agent 再接管。因为人和 agent 用的是同一个表面。

对称性。 Herdr 里编排者自己也跑在一个 pane 里,任何 pane 都可以编排其他 pane,没有固定的父子层级;Claude Code 的层级是 harness 定死的。

两者还可以组合:跑在 Herdr pane 里的 Claude Code 实例,可以装上 Herdr 的 agent skill(npx skills add herdrdev/herdr --skill herdr -g),用 CLI 再开几个 pane 派给其他 agent 实例——外层用终端表面做跨进程协调,内层保留进程内的 subagent 能力。

三、关键追问:上下文管理怎么办?

subagent 机制本来有两个作用:一是按任务选择合适的模型能力,二是上下文管理——尤其是保护主 agent 的 context 不被噪音淹没。那么 Herdr 这种方式,是不是只做到了第一点,第二点仍是瓶颈?

答案是:Herdr 完整保留了”隔离”性质,丢掉的是”压缩”性质。

隔离是怎么保留的

被派发 agent 是独立进程、独立 PTY、独立 agent CLI 实例,有自己独立的 context window。它工作 20 分钟读 50 个文件、跑测试、吐 10MB 输出,这些字节的完整去向是:

子进程 stdout → 子 pane 的 PTY → server 解析 → 该 pane 自己的终端状态/scrollback

终点是 server 内存里的字节缓冲,不是任何 agent 的 token。这不是”通常不会泄漏”,而是物理上没有泄漏路径:两边不共享进程、不共享内存、不共享 context;事件系统只推信号不推内容;内容进入 orchestrator context 的唯一入口是一次显式的、有界的 pane read。

走一遍生命周期,量化 orchestrator 的 context 消耗:

时刻发生什么orchestrator context 增量
t0agent prompt reviewer "..."prompt 本身(自己写的,几百 token)
t0–t20mreviewer 读 50 个文件、跑测试、吐 10MB0(阻塞在 server 侧等事件)
t20mwait 返回一个状态 JSON(几十 token)
t20m+agent read --lines 120≤120 行,可选

画成时序图,可以清楚看到”工作期间零读取”发生在哪里:

sequenceDiagram
    autonumber
    participant O as 主 pane 的 orchestrator
    participant S as herdr server
    participant A as 子 pane 的 agent(reviewer)

    O->>S: agent prompt reviewer …  --wait
    Note right of O: context +prompt(自己写的文本)
    S->>A: 文本 + 编码回车写入 PTY
    Note over S: 5 秒活动门:必须先观察到 working,<br/>不相关的 idle 不算数
    loop 工作期间(约 20 分钟)
        A->>S: stdout 字节流(读 50 个文件/跑测试,累计约 10MB)
        Note over S: 逐字节终端仿真 → 该 pane 的终端状态
    end
    Note over O: context 增量 = 0<br/>(阻塞发生在 server 侧等状态事件)
    S-->>O: settled 状态事件(idle / done / blocked)
    Note right of O: context +状态 JSON(几十 token)
    O->>S: agent read --source recent-unwrapped --lines 120
    S-->>O: 屏幕快照(≤120 行)
    Note over O: 总暴露量 ≈ prompt + 状态 + 一次有界读取

主 agent 对整个 10MB 探索的暴露量:prompt + 状态 + 一次有界读取——和 Claude Code”只有最终报告回流”是同一个量级。

而且有四个维度上,这种隔离比进程内边界更强:

  1. 下行也纯净。 Claude Code 的 fork 型 subagent 会继承父的全部对话历史,父的噪音会倒灌给子;Herdr 的派发只有 fresh-start 形式,子 agent 收到的全部输入就是那一条 prompt。双向零污染。
  2. 观察无副作用。 读取走 ReadIntent::Passive,不改变 pane 的 seen 状态(文档明确:focus 才 mark seen,read 不会)。观察者不干扰被观察者。
  3. 隔离在死亡之外存活。 orchestrator 崩溃、被压缩、甚至被人换掉,子 pane 的状态原封不动地在 server 里;新的 orchestrator attach 后 agent get 一次就恢复态势。进程内 subagent 的 session 一死,状态全灭。
  4. 第三方旁听。 人可以随时在 TUI 里看子 pane 的全程而不进入任何 agent 的循环。

真正丢掉的是什么

  1. 返回通道没有内建压缩。 Claude Code subagent 的返回值是子模型自己写的报告——压缩是免费的(它反正要生成)。Herdr 的 agent read 返回的是 raw 屏幕尾巴,信噪比取决于被派发 agent 的输出习惯,spinner、UI chrome 都混在里面。
  2. 边界是约定不是架构。 Claude Code 里中间转储不可能进父 context(harness 两端都拥有);Herdr 里一个不守纪律的 orchestrator 每 30 秒读 1000 行,照样把自己撑爆。
  3. Steering 循环有真实成本。 被派发 agent 卡在 approval 弹窗时,每次介入都要先读屏幕才能决策;这类多轮交互的累积噪音,Claude Code 是在 subagent 自己的循环里消化掉的。

核心经济学一句话:噪音以字节形式住在 server 里(便宜),只有经过显式、有界、拉式的转换才变成 token(贵)。 Claude Code 把隔离和压缩打包在同一个 harness 边界里出售;Herdr 把隔离做成了架构,把压缩降格成了纪律。

四、读取纪律:给架构缺口打的补丁

上一节的”纪律”不是虚指,它被明明白白写进了 Herdr 的 agent skill 文档(skills/herdr/SKILL.md)。这份文档教的就是一条从派发到回收的完整流水线:

1. 压缩在 prompt 时刻就发生了

skill 里的示例 prompt 是:

"Review the current diff and report only actionable findings."

注意措辞——“report only actionable findings”。纪律的第一条不是怎么读,而是派发时就要求子 agent 产出一个有界的、结论形态的、放在末尾的报告。这是个双方契约:子 agent 被告知把结论做成”屏幕形状”(终态、简短、在最后),orchestrator 才能用”读尾部”来收割。没有这个约束,屏幕尾部就只是”最后发生的事”,不是结论。

2. 同步全部在 server 侧,working 期间零读取

agent prompt --wait 阻塞等 settled 状态,工作期间 orchestrator 不做任何轮询读取。真正被纪律排除的不是”未完成状态”,而是 working 状态——TUI agent 工作时在备屏上刷 spinner 和部分输出,此刻读到的快照既是噪音又马上作废;settled 之后屏幕停止变化,尾部才是稳定的定格。状态是便宜的门闸(几十 token),内容是贵的货物,先状态后内容本质是给内容读取加信号门。

wait 的语义还专门防了一个坑:提交后必须先观察到 working 活动(最多等 5 秒),不相关的 idle 不算数。没有这个门,一个本来就没在干活的 agent 会立刻返回 idle,你随后读到的尾部是上一轮的旧输出而不是这轮 prompt 的结论。

3. 读取发生在三类 settled 时刻,用途不同

  • idle/done → 收割读取:读尾部拿 actionable findings;
  • blocked → 诊断读取:agent 停在 approval/question 弹窗,读屏幕是为了看懂弹窗在问什么(文档还要求先问人再代答);
  • timeout/stalled → 故障诊断读取:文档明确”timeout 不证明 prompt 没送达,不要盲目重新提交”——这条是防噪音放大,也防任务被重复执行。

整套门控行为画成流程图:

graph TD
    ST["发送 prompt<br/>(内含结论形态要求:<br/>report only actionable findings)"] --> W["server 侧阻塞等待<br/>working 期间零读取"]
    W -->|settled 状态事件| G{"哪个 settled 状态?"}
    G -->|idle / done| R1["收割读取:<br/>read 尾部拿结论"]
    G -->|blocked| R2["诊断读取:看懂弹窗,<br/>先问人再决定是否代答"]
    G -->|timeout / stalled| R3["故障诊断读取:get + read,<br/>不盲目重新提交"]
    R2 -->|send-keys 或人工介入后| W
    R3 -->|确认未送达才重提| W
    R1 --> ESC{"一次有界读取<br/>拿到完整结论?"}
    ESC -->|否,扩大 recent 读取仍不够| F["文件路径协议(仅兜底):<br/>子 agent 把完整报告写成 Markdown,<br/>屏幕只回路径,再直接读文件"]

4. 升级阶梯:读不动就换文件协议

最有想象力的一条:如果更大的屏幕读取仍找不到完整结论,让子 agent把完整报告写成 Markdown 文件、屏幕上只回一个文件路径,然后 orchestrator 直接读文件。但文档严格规定这只是 fallback:“do not request file output in the initial prompt”。常规情况交互成本最低,只有屏幕确实装不下结论时,才付出”两条命令 + 文件读取”的成本——而这本质上是把压缩步骤手工塞回被派发 agent 的 turn 里,重建 Claude Code 免费获得的东西。

整套纪律的形状:prompt 时压缩 → server 侧同步 → 先状态后内容 → 一次尾部有界读取 → 兜底换文件协议。它之所以存在,恰恰因为架构没有强制它——skill 文档花大量篇幅教读取纪律,正是架构缺的那半块被产品文档填补的痕迹。

结语

回到开篇的问题:Herdr 里主 agent 之所以能”实时”看到其他 pane 的输出,是因为所有终端输出天然汇聚到一个持续做终端仿真的 server,可见性变成了对共享状态的按需查询,而不是消息转发。

再往深一层,这个设计展示了一组干净的取舍:用 server 外置状态,Herdr 买到了隔离、持久性、人的可介入性和对任意 agent CLI 的通用性;代价是返回通道的压缩不再内建,只能靠 skill 文档把纪律补上。Claude Code 的进程内 subagent 正好是镜像:强制边界同时买到隔离和压缩,代价是失去中间过程的可观察性、对异构 agent 的通用性,以及 session 之外的生命力。

一个是”结论作为返回值”,一个是”屏幕作为共享内存”。两种范式的差异,大概率会在未来的多 agent 基础设施里继续共存下去——而且如前所述,它们本来就能叠着用。(工具层面 Herdr 与 session-share 的分工与配合,之前写过一篇《Herdr 和 session-share》,可作旁参。)

Sources

  1. Herdr 官网与文档
  2. agent skill 文档
  3. herdrdev/herdr 源码仓库 — Apache-2.0;文中引用的 src/pane.rs、src/app/api/panes.rs、src/app/api_helpers.rs、src/api/wait.rs、src/api/event_hub.rs、skills/herdr/SKILL.md 均在此仓库
  4. Claude Code subagents 官方文档

Related