首页 / 资讯中心 / 文章详情

给AI Agent造一个可编程终端:多会话隔离与MCP协议实践

给AI Agent造一个可编程终端:多会话隔离与MCP协议实践 ★ FEATURED ARTICLE
1. 为什么我要给 AI Agent 造一个终端1.1 从一次抓狂的调试说起事情的起因特别简单。那段时间我在用 Claude Code 做几个自动化任务比如批量重构一个老项目的目录结构、跑测试、修 lint 报错。单个任务跑得挺顺但只要我同时开两个 Claude Code 会话问题就来了——两个进程抢同一个终端输出混在一起我根本分不清哪行日志是哪个 Agent 打的。更别提有时候一个 Agent 卡在交互式命令上等输入另一个 Agent 的命令就全堵在后面了。我当时的第一反应是能不能给每个 Agent 分配一个独立的终端顺着这个思路往下想需求就越来越清晰了——不只是隔离我还想让 Agent 能真正“操控”终端能读屏幕内容、能发按键、能判断命令跑完没有甚至能让我在一个界面里同时盯着好几个 Agent 干活。市面上现成的终端工具我试了一圈Tabby 这类工具做人类用户的终端复用很成熟但它们的设计前提是“一个人操作一个终端”没有为“程序化操控”留出干净的接口。所以我就动手写了一个。核心目标就三条给 AI Agent 一个可编程的终端、支持多 Agent 并行、让 Claude Code 这类工具能通过标准协议直接调用。这篇文章就把整个设计思路、踩过的坑、以及可以直接抄的实操方案完整讲一遍。不管你是刚接触 Agent 开发还是已经在用 Claude Code 做自动化应该都能从里面捞到点有用的东西。1.2 这个终端到底解决什么问题先把定位说清楚。它不是要替代你日常用的终端模拟器而是一个面向 Agent 的终端运行时。你可以把它理解成一个“终端服务器”底层真正管理 PTY伪终端上层暴露一套结构化接口Agent 通过这套接口来创建会话、发送输入、读取输出、监听状态变化。它解决的核心痛点有三个。第一是会话隔离每个 Agent 拿到自己的 PTY互不干扰这是并行指挥的前提。第二是可编程性传统终端是给人眼看的输出是一坨带 ANSI 转义码的字节流Agent 很难直接理解我的终端会把输出做结构化处理剥离控制字符、标记命令边界让 Agent 拿到的是干净、可判断的文本。第三是多路复用与统一观测我可以在一个面板里看到所有 Agent 的实时状态谁在跑、谁在等、谁挂了一目了然。适合谁来参考如果你在做 AI Agent 开发、想让 Agent 具备真实的命令行操作能力、或者你正在用 Claude Code 但被单会话限制卡住那这套东西就是为你准备的。哪怕你只是想搞明白 MCP 到底怎么把终端能力接给模型后面的协议部分也值得一看。2. 整体架构设计与技术选型2.1 为什么底层选了 Rust技术选型这块我纠结了挺久。候选方案无非是 Node.js、Go、Rust 三个。Node 生态里 node-pty 很成熟上手快Go 的并发模型天然适合这种多会话场景Rust 则是性能和资源占用最优但开发成本高。最后我选了 Rust理由有三个而且都是被实际需求逼出来的。第一PTY 操作是系统级调用涉及 fork、exec、文件描述符管理Rust 的 nix 库对这些底层原语封装得很干净类型系统能帮我在编译期挡掉一大堆 fd 泄漏和竞态问题。第二多 Agent 并行意味着高并发 I/O每个会话都要持续读 PTY 输出Rust 的 async 运行时tokio在这种场景下资源占用极低我实测同时跑 8 个 Agent内存稳定在 60MB 左右换成 Node 早就上 300MB 了。第三这个终端要长期驻留作为后台服务被反复调用Rust 没有 GC 停顿长时间运行不会出现内存抖动。当然代价也有Rust 的异步 生命周期组合写起来确实费脑子尤其是跨任务的 PTY 句柄管理我在这上面返工了两次。但跑通之后那种“一次写对、长期稳定”的感觉值了。2.2 分层结构PTY 层、会话层、协议层整个系统我分了三层每层职责单一方便单独测试和替换。最底下是PTY 层直接跟操作系统打交道。它负责创建伪终端对master/slave、fork 出子进程、把 slave 端接到子进程的标准输入输出上。这一层是纯系统编程不掺任何业务逻辑。我用 nix 的openpty拿到一对 fd然后fork之后在子进程里setsid、把 slave 设为控制终端、execvp启动目标 shell。这套流程是 Unix 终端编程的标准套路但细节特别多后面实操部分我会展开。中间是会话层管理每个 Agent 的会话生命周期。一个会话包含PTY 句柄、一个读任务持续从 master fd 读数据、一个写通道接收要发送的输入、一个状态机记录当前是空闲、运行中还是等待输入。会话层还负责输出缓冲和命令边界检测——这是让 Agent 能“看懂”终端的关键。最上面是协议层对外暴露接口。我实现了两套一套是本地 IPCUnix socket给同机的程序调用另一套是MCP 协议这样 Claude Code 这类支持 MCP 的客户端可以直接把终端能力当成工具来用。MCP 这块是重点也是热词里反复出现的后面单独讲。2.3 多 Agent 并行的调度模型多 Agent 并行不是简单地开多个线程就完事。真正的难点在于资源竞争和状态同步。比如两个 Agent 同时想写文件、同时想占用某个端口这些冲突如果不在调度层处理就会变成随机失败。我的做法是给每个会话分配独立的 PTY 和独立的工作目录从物理上隔离。然后在会话层之上加了一个轻量的调度器它维护一张会话表记录每个会话的 ID、状态、当前工作目录、最近一次活动时间。调度器不主动干预 Agent 的命令但会做两件事一是限流防止某个 Agent 疯狂输出把内存打爆二是超时回收一个会话如果长时间无活动自动标记为僵尸并清理。这里有个设计取舍值得说。我一开始想做“智能调度”让调度器根据任务优先级分配 CPU 时间片。后来发现完全没必要——Agent 的命令执行本身就是短时突发操作系统的时间片调度已经够用我加的那层反而引入了复杂度。所以最后砍掉了只保留状态管理和资源回收。能不做就不做这是我在这个项目里学到的最重要一课。3. 核心细节解析与实操要点3.1 PTY 创建那些文档不会告诉你的坑PTY 创建看着简单实际坑最多。标准流程是openpty拿到 master 和 slave 两个 fdfork子进程里setsid建立新会话然后用ioctl(TIOCSCTTY)把 slave 设为控制终端最后dup2把 slave 复制到 stdin/stdout/stderrexecvp启动 shell。第一个坑是控制终端。如果你忘了setsid或者TIOCSCTTY子进程里的程序就不知道自己有个终端很多依赖终端的程序比如 top、vim会直接报错或者行为异常。我一开始就漏了这步结果 Claude Code 跑交互式命令时各种诡异表现查了半天才发现是控制终端没设对。第二个坑是窗口大小。PTY 默认的窗口尺寸可能是 0x0 或者 80x24如果不设置某些程序会根据窗口大小决定输出格式尺寸不对会导致输出错乱。解决办法是在创建后立刻用ioctl(TIOCSWINSZ)设置一个合理的尺寸我一般设成 200x50够宽够高避免程序做换行截断。第三个坑是fd 泄漏。fork 之后父进程必须关掉 slave fd子进程必须关掉 master fd否则会出现“终端关了但进程还在”的僵尸状态。这个在 Rust 里靠所有权系统能挡掉一部分但跨 fork 的场景还是得手动处理我用了OwnedFd来确保 drop 时自动关闭。提示调试 PTY 问题时strace是你的好朋友。用strace -f -e traceioctl,dup2,fork,execve跟一遍能快速定位是哪一步没做对。3.2 输出结构化让 Agent 读懂终端传统终端的输出是给眼睛看的里面混着 ANSI 转义码颜色、光标移动、清屏指令还有各种控制字符。Agent 拿到这坨东西根本没法判断“命令跑完了没有”“输出里有没有报错”。我的处理分三步。第一步是剥离 ANSI 转义码用一个状态机扫描字节流遇到 ESC 开头的序列就跳过。这一步不能简单用正则因为转义序列有嵌套和变长的情况状态机才靠谱。第二步是命令边界检测我在发送命令时会附带一个唯一的哨兵标记比如echo __CMD_DONE_uuid__读到这个标记就说明命令执行完了可以提取中间的输出。第三步是输出归一化把\r\n统一成\n去掉行尾多余空格让文本干净可解析。这套机制让 Agent 能拿到结构化的结果一段纯文本输出 一个明确的完成信号 退出码。Claude Code 拿到这个之后判断逻辑就简单多了不用再去猜终端状态。3.3 MCP 协议接入把终端变成 Agent 的工具MCPModel Context Protocol是这套方案里最关键的连接点。简单说它是一套标准协议让模型能发现和调用外部工具。我把终端能力包装成几个 MCP 工具暴露出去create_session创建会话、send_input发送输入、read_output读取输出、list_sessions列出所有会话、close_session关闭会话。Claude Code 通过 MCP 连上我的终端服务后就能像调用内置工具一样调用这些能力。比如它想跑一个测试就会先create_session然后send_input发npm test再read_output拿结果最后根据输出决定下一步。整个过程对模型来说是透明的它不需要知道底层是 PTY 还是别的什么。MCP 的实现要点在于工具描述要清晰。模型是根据工具的名称、描述和参数 schema 来决定怎么调用的描述写得含糊模型就会乱调。我在每个工具的描述里都写清楚了用途、参数含义和返回值格式实测下来调用准确率明显提升。另外要注意错误处理MCP 工具调用失败时要返回结构化的错误信息而不是直接抛异常否则模型会一脸懵。3.4 会话状态机判断 Agent 在干什么会话状态机是我觉得设计得最巧妙的一块。每个会话有四个状态Idle空闲等待输入、Running命令执行中、WaitingInput命令在等用户输入比如确认提示、Dead进程已退出。状态怎么判断Running和Idle靠哨兵标记区分读到哨兵就是Idle没读到就是Running。WaitingInput比较 tricky我的做法是检测输出是否在一段时间内停止增长同时最后一行没有换行符——这通常意味着程序在等输入。这个启发式规则不是 100% 准确但覆盖了绝大多数场景误判率很低。状态机的好处是让调度器能做决策。比如一个会话长时间处于WaitingInput调度器可以通知我“这个 Agent 卡住了需要人工介入”而不是傻等。多 Agent 并行时这个能力特别重要否则你根本不知道哪个 Agent 出了问题。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把环境搭起来。我假设你在 Linux 或 macOS 上Windows 的话建议用 WSL2原生 Windows 的 PTY 支持是另一套 APIConPTY不在本文范围内。Rust 环境用 rustup 装这个不用多说。核心依赖就几个tokio做异步运行时nix做系统调用serde和serde_json做序列化rmcp或类似的库做 MCP 服务端。Cargo.toml 大概长这样[dependencies] tokio { version 1, features [full] } nix { version 0.27, features [term, process, fs] } serde { version 1, features [derive] } serde_json 1 uuid { version 1, features [v4] }装完之后先跑个最小验证创建一个 PTY启动/bin/bash发一条echo hello看能不能读到输出。这一步跑通后面才有意义。4.2 创建 PTY 会话的完整代码核心的 PTY 创建逻辑我抽成了一个函数输入是 shell 路径和工作目录输出是一个会话句柄。关键步骤如下use nix::pty::{openpty, OpenptyResult, Winsize}; use nix::unistd::{fork, ForkResult, setsid, dup2, execvp}; use nix::sys::termios::Termios; use std::os::fd::{AsRawFd, OwnedFd}; fn spawn_session(shell: str, cwd: str) - Session { // 1. 打开 PTY 对 let winsize Winsize { ws_row: 50, ws_col: 200, ws_xpixel: 0, ws_ypixel: 0 }; let OpenptyResult { master, slave } openpty(Some(winsize), None).unwrap(); // 2. fork match unsafe { fork() }.unwrap() { ForkResult::Child { // 子进程建立会话、设控制终端、接 fd setsid().unwrap(); unsafe { libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY, 0) }; dup2(slave.as_raw_fd(), 0).unwrap(); dup2(slave.as_raw_fd(), 1).unwrap(); dup2(slave.as_raw_fd(), 2).unwrap(); std::env::set_current_dir(cwd).unwrap(); let c_shell std::ffi::CString::new(shell).unwrap(); execvp(c_shell, [c_shell]).unwrap(); unreachable!(); } ForkResult::Parent { .. } { // 父进程关掉 slave保留 master drop(slave); Session::new(master) } } }这段代码有几个细节必须注意。openpty的第二个参数是 termios 设置传None会用默认值一般够用。TIOCSCTTY那个unsafe块是必须的因为这是裸系统调用。execvp之后如果返回了说明执行失败必须unreachable!或者exit否则子进程会继续跑父进程的代码那就乱套了。4.3 读写循环与哨兵机制实现会话创建好之后要起一个异步任务持续读 master fd。这里用 tokio 的AsyncFd把裸 fd 包成异步的避免阻塞运行时。async fn read_loop(master: OwnedFd, tx: mpsc::SenderVecu8) { let async_fd AsyncFd::new(master).unwrap(); let mut buf [0u8; 4096]; loop { let mut guard async_fd.readable().await.unwrap(); match guard.try_io(|inner| { nix::unistd::read(inner.as_raw_fd(), mut buf) }) { Ok(Ok(n)) if n 0 { tx.send(buf[..n].to_vec()).await.unwrap(); } _ break, // EOF 或错误会话结束 } } }哨兵机制在发送端实现。每次发命令时我在命令后面追加一个唯一标记let cmd_id Uuid::new_v4(); let full_cmd format!({}\necho __DONE_{}__\n, user_cmd, cmd_id); write_to_pty(full_cmd).await;然后在读取端扫描输出遇到__DONE_cmd_id__就认为命令执行完毕把之前的输出作为结果返回。这个标记用 UUID 是为了避免和命令本身的输出冲突实测下来几乎不可能撞车。4.4 多 Agent 并行的实测记录我拿一个真实场景测了一下同时启动 4 个 Claude Code 会话分别做四件事——跑单元测试、重构一个模块、生成文档、修 lint 错误。每个会话独立 PTY、独立工作目录。实测结果4 个会话并行跑CPU 占用峰值 40%4 核机器内存稳定在 80MB 左右。所有会话的输出互不干扰我通过list_sessions能实时看到每个会话的状态。有一个会话卡在了WaitingInput某个命令弹了确认提示调度器标记出来后我手动介入处理其他三个不受影响继续跑。对比之前单会话串行跑总耗时从大约 25 分钟降到 8 分钟。这个提升主要来自并行而不是单个任务变快了。多 Agent 的价值就在于把等待时间重叠起来尤其是那些 I/O 密集的任务比如跑测试、装依赖并行收益非常明显。5. 常见问题与排查技巧实录5.1 输出乱码或丢字符这是最常见的问题表现是 Agent 读到的输出缺字、错位、或者混入奇怪的字符。原因通常有三个。第一是读取缓冲区太小。如果一次读的字节数小于 PTY 缓冲区里的数据量就会分多次读中间可能被其他操作打断。解决办法是把缓冲区设大一点我用 4096并且保证读取是连续的不要在中间插入其他 await。第二是没处理 UTF-8 边界。多字节字符可能被切在两个读取块之间直接按字节转字符串就会乱码。正确做法是维护一个字节缓冲每次读取后尝试解码解码失败就保留剩余字节等下次。我用的是String::from_utf8_lossy配合手动缓冲效果不错。第三是ANSI 转义码没剥干净。有些程序会发复杂的转义序列简单的正则处理不掉。这时候要检查你的状态机是不是漏了某些序列类型比如 OSC操作系统命令序列是以ESC ]开头、BEL或ESC \结尾的很容易漏。5.2 会话卡死无响应会话卡死通常是因为命令在等输入但 Agent 不知道。排查步骤是这样的先用list_sessions看状态如果是WaitingInput说明程序在等输入如果是Running但很久没输出可能是命令真的卡住了比如死循环。对于WaitingInput我的处理是给会话加一个“自动应答”机制对于常见的确认提示比如[y/N]可以配置自动回复。但要注意不是所有提示都能自动应答有些需要根据上下文判断这时候就得通知人工介入。对于Running卡死加超时机制。每个命令发送时附带一个超时时间超过就强制中断发CtrlC或者直接 kill 进程组。这里要注意杀进程组而不是单个进程因为 shell 启动的命令可能是一整棵进程树只杀 shell 会留下孤儿进程。5.3 MCP 连接失败排查MCP 连接失败的表现是 Claude Code 找不到工具或者调用时报错。排查顺序如下。先确认 MCP 服务端在跑端口或 socket 路径对不对。然后检查客户端的 MCP 配置路径、命令、参数有没有写错。我踩过的一个坑是路径用了相对路径客户端的工作目录和服务端不一样导致找不到可执行文件。改成绝对路径就好了。再就是协议版本不匹配。MCP 还在演进不同版本的客户端和服务端可能有兼容问题。解决办法是看日志MCP 握手时会交换版本信息不匹配会有明确报错。实在不行就升级到同一版本。问题现象可能原因排查方法解决方案输出乱码缓冲区小 / UTF-8 边界加大缓冲、检查解码逻辑用字节缓冲 增量解码会话卡死等待输入 / 命令死循环查状态机、看最后输出自动应答 超时中断MCP 连不上路径错 / 版本不匹配看握手日志绝对路径 统一版本fd 泄漏fork 后没关 fdlsof 查句柄数用 OwnedFd 自动管理内存暴涨输出无限缓冲监控内存曲线加环形缓冲 限流5.4 独家避坑心得分享几个文档里不会写、但实际特别重要的经验。第一永远给会话设上限。我一开始没设结果一个 Agent 疯狂输出跑了个死循环打印内存几分钟就涨到几个 G。后来加了会话数上限默认 16和单会话输出缓冲上限默认 1MB 环形缓冲超了就丢旧数据稳多了。第二日志要分级。调试期我把所有 PTY 原始字节都打日志结果日志文件一天几十 G。后来改成默认只记结构化事件会话创建、命令发送、状态变化原始字节只在 debug 级别记问题少多了。第三测试要覆盖异常路径。正常流程好测难的是异常命令中途被 kill、PTY 突然 EOF、客户端断连。这些场景我都是手动构造的比如用kill -9杀子进程看父进程反应用nc模拟客户端断连。这些测试帮我提前发现了好几个 bug。第四别迷信“智能”。前面说过我砍掉了智能调度其实还砍过别的比如“自动修复命令错误”。想法很美好实际是模型经常改错还不如把错误原样返回让上层决策。工具做好工具的事决策交给 Agent这个边界要守住。6. 这套方案还能怎么扩展6.1 接入更多 Agent 框架目前我主要接的是 Claude Code但 MCP 是通用的理论上任何支持 MCP 的客户端都能接。我试过用其他 Agent 框架通过 MCP 连进来基本不用改服务端代码只要客户端支持就行。这意味着这套终端能力可以复用到多个 Agent 生态里不用为每个框架单独适配。如果你用的框架不支持 MCP也可以走本地 IPC 那套接口自己写个适配层。接口设计得比较通用create/send/read/close 四个动作映射到任何框架都不难。6.2 从单机到分布式现在的实现是单机的所有会话跑在同一台机器上。如果要扩展到多机思路是把会话层和 PTY 层拆开PTY 层跑在各个工作节点上会话层做中心调度。节点之间用 gRPC 或者消息队列通信中心节点维护全局会话表。这个扩展我没做因为目前单机够用。但架构上留了口子会话层和 PTY 层之间的接口是抽象的换成远程调用不用改上层逻辑。真要做的话难点在网络分区和故障恢复——节点挂了上面的会话怎么办我的想法是会话状态定期快照节点恢复后能重建但这块还没验证。6.3 安全边界要提前想清楚最后说个严肃的话题。给 Agent 一个能执行任意命令的终端本质上是把机器的控制权交出去了。所以安全边界必须提前设计不能事后补。我的做法有几层。第一会话运行在受限环境里可以用容器或者 namespace 隔离限制文件系统访问和网络。第二命令白名单/黑名单危险命令比如rm -rf /直接拦截。第三审计日志所有命令和输出都记录出问题能追溯。第四权限最小化跑 Agent 的用户只给必要的权限别用 root。这些措施会增加一些复杂度但比起出事之后的代价完全值得。我在实际使用中即使是自己写的 Agent也坚持跑在隔离环境里这是底线。这套终端我陆陆续续迭代了两个多月从最初只想解决“多会话隔离”到后来加上 MCP、状态机、调度器功能越来越多但核心始终没变给 Agent 一个干净、可控、可观测的命令行执行环境。如果你也在做类似的事希望这篇能帮你少走点弯路。有具体实现上的问题欢迎一起交流。
阅读完成 · 觉得有帮助?
咨询建站