Agent 工程实战:用 Rust 构建模型循环之外的 mini-harness
1. 为什么“模型循环之外”才是 Agent 工程的真正战场
先把结论摆在前面:一个 Agent 能不能在真实场景里跑起来,模型本身只占三成,剩下七成全是 harness 的活。很多人第一次做 Agent,脑子里想的都是“怎么把 prompt 调好”“怎么让模型多轮思考”,结果代码写到一半发现,真正让人崩溃的是工具调用失败怎么重试、上下文超长了怎么裁剪、并发上来之后状态怎么隔离、沙盒里的进程挂了怎么回收。这些问题跟模型聪不聪明一点关系都没有,全是工程问题。
mini-harness这个项目名字本身就点题了——mini 是体量,harness 是本质。harness 这个词在 Agent 语境里,指的是包裹在模型外面那一层“挽具”:它负责把模型的输出翻译成真实世界的动作,再把动作的结果翻译回模型能理解的输入。模型循环(model loop)只是 harness 驱动的一个子过程,真正难的部分全在循环之外。
我见过太多团队把 80% 的精力砸在 prompt 工程上,最后上线发现 Agent 在 demo 里能跑,一进生产就各种超时、死锁、状态污染。原因很简单:他们把一个分布式系统的问题,当成了一个自然语言处理的问题。mini-harness想做的事情,就是把这层被忽视的工程骨架单独拎出来,用 Rust 写一个足够小、足够硬、足够可控的 harness 内核。
这篇文章适合三类人看:第一类是想自己从零搭 Agent 但被工程细节卡住的开发者;第二类是用过现成 Agent 框架但觉得“黑盒太重、改不动”的工程师;第三类是对 Rust 在 AI 基础设施里的定位感兴趣、想找一个真实项目练手的人。下面我会围绕 harness 的职责边界、Rust 选型的真实理由、模型循环之外那几个必须自己写的模块、以及并发与沙盒这些硬骨头,把mini-harness这类项目的设计逻辑拆开讲。
2. harness 和 agent 到底差在哪:一次概念上的彻底厘清
2.1 把 Agent 拆成“大脑”和“身体”两层来看
很多人把 Agent 和 harness 混着用,其实这两个词描述的是不同层次的东西。Agent 是一个完整的能力单元,它包含决策逻辑、记忆、工具使用、目标管理等等;而 harness 是承载这些能力的运行时骨架。打个比方,Agent 是驾驶员,harness 是那辆车——驾驶员决定去哪、怎么开,但方向盘转多少度能对应车轮转多少度、油门踩下去发动机多久响应、刹车失灵时怎么降级,这些全是车的事。
在代码层面,这个区分会更清楚。模型循环通常长这样:把消息历史发给模型,拿到一个 tool_call,执行工具,把结果塞回历史,再发给模型,如此往复直到模型输出最终答案。这个循环本身逻辑非常简单,二十行代码就能写出来。但 harness 要管的是:消息历史用什么数据结构存、工具执行超时了怎么办、模型返回了非法 JSON 怎么兜底、多个 Agent 同时跑怎么隔离、上下文快满了怎么压缩、执行环境崩了怎么恢复。
一个判断标准:如果你写的代码在换一个模型之后完全不用改,那它大概率属于 harness;如果换个模型就得重写,那它属于模型适配层。
2.2 模型循环之外,harness 至少要扛住五件事
我把 harness 的职责归纳成五块,每一块都是模型循环本身不解决的:
第一块是工具注册与调度。模型只会说“我要调用 search 工具,参数是 xxx”,但 search 工具从哪来、怎么注册、参数怎么校验、返回值怎么序列化,全是 harness 的事。工具多了之后还要考虑按需加载,不能把所有工具描述一股脑塞进 prompt。
第二块是上下文生命周期管理。模型的上下文窗口是有限的,但 Agent 跑久了消息会无限增长。什么时候裁剪、裁剪哪部分、裁剪后怎么保证语义不丢,这是 harness 的核心算法之一。
第三块是执行环境隔离。Agent 要执行代码、读写文件、发网络请求,这些动作必须在一个受控环境里跑。沙盒怎么建、资源怎么限、进程怎么回收,模型循环完全不管。
第四块是状态与持久化。Agent 跑到一半崩了,重启之后能不能接着跑?多轮对话的状态存在哪?这些是 harness 要设计的存储层。
第五块是可观测性。Agent 为什么做了这个决策、哪一步耗时最长、哪个工具调用失败了,这些 trace 信息必须由 harness 采集,模型自己是不会告诉你的。
mini-harness的价值就在于,它把这五块用一个足够小的代码库表达出来,让你能看懂、能改、能嵌到自己的系统里,而不是被一个几万行的框架绑死。
2.3 为什么现成框架经常“不好用”
市面上的 Agent 框架不少,但用过的都知道,它们普遍有两个问题:一是抽象层次太高,你想改一个细节得翻好几层封装;二是把太多东西耦合在一起,模型适配、工具管理、编排逻辑全缠在一块,换个模型可能整个链路都要动。
mini-harness这类项目的思路是反过来的:只做 harness 该做的事,模型适配交给独立的 provider 层,编排逻辑交给上层。这样 harness 本身可以做得非常薄、非常稳。薄意味着容易审计,稳意味着可以长期不动。在 Agent 这种快速迭代的领域,把最底层的运行时做稳,比把最上层的功能做多更重要。
3. 用 Rust 写 harness:不是跟风,是被逼的
3.1 Agent 运行时的三个硬指标,Rust 刚好都对上
选 Rust 写 harness,不是因为 Rust 火,而是因为 Agent 运行时对语言有三个硬要求,Rust 恰好都满足。
第一个要求是低且可预测的延迟。Agent 的每一步都要等模型返回,模型本身已经够慢了,如果 harness 自己再引入 GC 停顿或者调度抖动,整体体验会更差。Rust 没有 GC,内存回收是确定性的,这对需要长时间稳定运行的 Agent 服务很关键。
第二个要求是高并发下的内存安全。一个 harness 可能要同时跑几百个 Agent 会话,每个会话有自己的状态、工具、沙盒。用带 GC 的语言写,并发一上来内存压力和停顿都很难控;用 C++ 写,又容易出内存安全问题。Rust 的所有权模型在编译期就把数据竞争挡掉了,这对多会话隔离特别友好。
第三个要求是单二进制部署。harness 经常要嵌到各种环境里——本地 CLI、容器、边缘设备。Rust 编译出来是一个静态二进制,不依赖运行时,扔哪都能跑。这一点在把 Agent 部署到资源受限环境时优势非常明显。
3.2 异步运行时怎么选:tokio 是默认答案,但别乱用
Rust 生态里做异步,tokio基本是默认选择。mini-harness这种要处理大量 IO(模型请求、工具调用、文件读写)的场景,异步是必须的。但用 tokio 有几个坑得提前说。
第一,别在异步上下文里做阻塞操作。比如读一个大文件、跑一个 CPU 密集的计算,这些必须扔到spawn_blocking里,否则会把整个 executor 的线程卡住。我见过有人直接在 async 函数里调同步的文件 API,结果并发一上来整个服务假死。
第二,任务取消要设计好。Agent 会话可能被用户中途取消,这时候正在跑的工具调用、正在等的模型响应都要能干净地取消掉。tokio 的CancellationToken配合select!是标准做法,但要注意取消之后资源的清理,别留下孤儿进程。
第三,channel 的选择有讲究。会话内部通信用mpsc,广播事件用broadcast,一次性结果用oneshot。选错了要么性能差,要么逻辑别扭。mini-harness里会话状态更新我一般用mpsc,因为要保证顺序;事件通知用broadcast,因为多个订阅者都要收到。
3.3 错误处理:Agent 场景下anyhow和thiserror怎么分工
Rust 的错误处理是它的一大优势,但在 harness 这种场景下要分清楚两种错误。
一种是库内部的、需要被调用方区分的错误,比如“工具未找到”“参数校验失败”“沙盒启动失败”。这种用thiserror定义成枚举,调用方可以 match 出来做不同处理。
另一种是跨层传递的、只需要记录和上报的错误,比如模型 API 返回了一个奇怪的响应。这种用anyhow包一层上下文就够了,不需要调用方区分具体类型。
我的经验是:harness 的公共 API 边界用thiserror,内部实现用anyhow。这样对外接口清晰,对内写起来不啰嗦。另外,Agent 场景下错误信息一定要带足够的上下文——哪个会话、哪一步、哪个工具,不然线上排查会非常痛苦。
4. 模型循环之外,mini-harness 必须自己实现的几个模块
4.1 工具注册表:从“硬编码 if-else”到“可插拔注册”
最开始写 Agent,工具调用经常是一堆 if-else:如果模型说要调 search 就调 search 函数,说要调 calc 就调 calc 函数。工具一多,这个函数就没法看了。mini-harness的做法是搞一个工具注册表。
核心是一个 trait,大概长这样:
#[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn schema(&self) -> serde_json::Value; async fn call(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError>; }每个工具实现这个 trait,然后注册到一个HashMap<String, Box<dyn Tool>>里。模型返回 tool_call 时,harness 按名字查表、校验参数、执行、把结果序列化回去。
这里有几个设计细节值得说。第一,schema()返回的是 JSON Schema,用来生成给模型看的工具描述,也用来做参数校验。第二,call是 async 的,因为工具执行通常是 IO 密集的。第三,ToolOutput要区分“成功的结果”和“给模型看的错误信息”——工具执行失败不一定要中断整个循环,很多时候把错误信息喂回模型,模型自己会调整。
实操心得:工具描述别写太长。我见过有人把工具描述写成一篇小作文,结果光工具描述就占了几千 token。描述要精炼到模型能判断“什么时候该用这个工具”就够了,细节放在参数 schema 里。
4.2 上下文压缩:不是简单截断,而是有策略地遗忘
上下文管理是 harness 里最容易被低估的模块。模型窗口就那么大,Agent 跑久了消息必然超限。最简单的做法是保留最近 N 条,但这样会丢掉早期的关键信息,比如用户最初的目标。
mini-harness里我一般用分层策略:
- 系统提示和初始目标永远保留,这是 Agent 的“锚”。
- 最近的若干轮对话完整保留,保证短期连贯性。
- 中间的历史做摘要压缩,用一个便宜的模型把多轮对话压成一段总结。
- 工具调用的原始输出如果很长,只保留关键部分,其余截断并标注。
压缩的触发时机也有讲究。不要等到快满了才压,那样每次压缩都很重。我一般设两个阈值:到 70% 时开始后台异步压缩,到 90% 时如果还没压完就强制截断兜底。
这里有个坑:压缩会改变消息序列,可能让模型的行为发生变化。所以压缩后的摘要要尽量保留“谁在什么时候做了什么决定”这类结构化信息,而不是笼统地说“之前进行了一些对话”。
4.3 会话状态机:Agent 不是无状态的函数调用
很多人把 Agent 当成一个无状态的函数:输入消息,输出结果。但真实 Agent 是有状态的,它可能处于“等待用户输入”“正在执行工具”“等待工具结果”“已完成”等不同状态。mini-harness用一个显式的状态机来管理这个。
状态机的价值在于,它让“Agent 跑到一半崩了怎么恢复”这个问题变得可解。每个状态转换都持久化,重启之后从最后一个稳定状态恢复。比如 Agent 正在等一个工具返回,这时候进程挂了,重启后 harness 发现状态是“等待工具结果”,就可以重新发起那个工具调用,而不是从头再来。
状态机的另一个好处是并发控制。同一个会话同时只能有一个状态转换在进行,用一把锁或者一个 actor 模型来保证。这样就不会出现两个请求同时改同一个会话状态导致数据错乱。
4.4 可观测性:trace 不是可选项,是必需品
Agent 出问题的时候,你光看最终输出是没法排查的。你需要知道:模型收到了什么、返回了什么、调用了哪个工具、工具返回了什么、每一步耗时多少。这些信息必须由 harness 在每一步采集。
mini-harness里我用一个事件流来承载这些信息。每个关键节点发一个事件,事件带时间戳、会话 ID、步骤类型、payload。这些事件可以打到日志、可以发到 trace 系统、也可以实时推给前端做可视化。
这里的关键是事件要结构化,不能是随便拼的字符串。我一般定义成枚举,每种事件类型有自己的字段。这样下游消费的时候可以按类型处理,而不是去解析字符串。
注意:trace 里可能包含用户隐私数据,采集的时候要做好脱敏。工具调用的参数和返回值尤其要注意,别把敏感信息原样打出来。
5. 并发与沙盒:Agent 上生产绕不过的两道坎
5.1 多会话并发:隔离做不好,一个会话能拖垮全部
Agent 服务通常是多会话并发的,每个会话有自己的消息历史、工具状态、执行环境。如果隔离做不好,会出现各种诡异问题:A 会话的工具调用影响了 B 会话、一个会话的内存泄漏拖垮整个进程、某个会话死循环占满 CPU。
mini-harness的隔离策略是分层的:
- 数据隔离:每个会话有独立的
SessionState,通过会话 ID 索引,互不共享可变状态。 - 执行隔离:工具执行在独立的 task 里跑,有超时和资源限制。
- 资源隔离:对每个会话的并发工具调用数、内存占用设上限,超了就排队或拒绝。
这里有个反直觉的点:不要追求完全无锁。有些共享状态(比如工具注册表、模型 provider 的连接池)本来就是只读或低频写的,用Arc共享完全没问题。过度追求无锁反而会让代码复杂到没法维护。真正需要隔离的是会话的可变状态,那部分用独立的所有权来保证。
5.2 沙盒执行:Agent 要跑代码,怎么跑才安全
Agent 经常需要执行代码——算个数、处理个文件、跑个脚本。直接在主进程里eval是绝对不行的,必须放到沙盒里。mini-harness的沙盒设计要考虑几件事:
第一,进程隔离。每次代码执行起一个独立进程,用容器或者轻量级沙盒技术限制它能访问的资源。进程挂了不影响主进程。
第二,资源限制。CPU 时间、内存、磁盘、网络都要设上限。一个死循环的脚本不能把机器跑满。
第三,超时回收。执行超过设定时间就强制杀掉,并且要确保子进程真的被回收,别留下僵尸进程。
第四,输出限制。Agent 执行的代码可能输出海量内容,harness 要限制捕获的输出大小,超了就截断。
在 Rust 里做进程管理,tokio::process::Command是基础,配合kill_on_drop和超时控制。如果要更强的隔离,可以对接容器运行时,但那会增加部署复杂度,mini-harness这种定位一般用进程级隔离加资源限制就够了。
5.3 背压:并发上来了,怎么让系统不崩
Agent 服务最容易崩的时刻,就是并发突然上来。每个请求都要调模型、跑工具,资源消耗很大。如果没有背压机制,请求会堆积,内存暴涨,最后 OOM。
mini-harness的背压策略是:对模型调用和工具执行都设并发上限,用信号量控制。超过上限的请求排队等待,队列也满了就直接拒绝并返回明确错误。这样虽然会拒绝一部分请求,但保证了已接受的请求能正常完成,整体可用性更高。
这里有个经验值:模型调用的并发上限,一般设成模型 API 的速率限制的 80% 左右,留点余量应对突发。工具执行的并发上限,根据工具类型定——纯计算的可以高一点,涉及外部 IO 的要低一点。
6. 把 mini-harness 嵌进真实项目时的几个决策点
6.1 模型 provider 层怎么和 harness 解耦
mini-harness本身不应该关心用的是哪个模型。它只定义模型接口,具体实现交给 provider 层。接口大概是这样:
#[async_trait] pub trait ModelProvider: Send + Sync { async fn complete( &self, messages: &[Message], tools: &[ToolSchema], ) -> Result<ModelResponse, ModelError>; }不同的模型厂商实现这个 trait,harness 只依赖 trait。这样换模型的时候,harness 一行不用改。provider 层可以处理各家 API 的差异——有的用tool_calls字段,有的用function_call,有的返回格式还不一样,这些都在 provider 里抹平。
这个解耦的价值在模型快速迭代的今天特别明显。今天用 A 模型,明天可能换 B 模型,如果 harness 和模型耦合了,每次换都要大改。解耦之后,换模型只是换一个 provider 实现。
6.2 持久化选型:从内存到数据库的平滑过渡
mini-harness早期可以纯内存跑,会话状态存在HashMap里。但一旦要支持重启恢复、多实例部署,就必须持久化。
持久化的选型要看场景。单机小规模,用 SQLite 就够了,零依赖、单文件、事务完整。要支持多实例,得上 PostgreSQL 或者 Redis。mini-harness的做法是定义一个SessionStoretrait,内存实现和数据库实现都实现这个 trait,按需切换。
这里要注意的是写入频率。Agent 每一步状态变化都写数据库,写入压力会很大。我的做法是:关键状态转换(比如工具调用前后)同步写,中间过程异步批量写。这样既保证了恢复点的可靠性,又不会把数据库打爆。
6.3 部署形态:CLI、服务、还是嵌入式
mini-harness因为体量小,可以有好几种部署形态。作为 CLI 工具,直接编译成二进制,本地跑;作为服务,包一层 HTTP 或者 gRPC 接口;作为嵌入式库,被其他 Rust 项目依赖。
这三种形态对 harness 的要求不太一样。CLI 形态对启动速度敏感,服务形态对并发和可观测性敏感,嵌入式形态对 API 简洁性敏感。mini-harness的设计是核心逻辑和部署形态分离,核心是一个库,CLI 和服务都是薄薄的包装层。
实操建议:如果你不确定最终部署形态,先把核心做成库,CLI 和服务后面加。这样不会因为早期选错形态而返工。
7. 我在实际折腾 harness 时踩过的几个坑
第一个坑是过度设计工具抽象。一开始我想搞一个万能工具接口,支持各种参数类型、各种返回格式,结果抽象层厚得没法看,加一个新工具要写一堆样板代码。后来简化成“JSON 进 JSON 出”,工具内部自己解析,反而清爽多了。Agent 场景下,工具的参数和返回值本来就是 JSON,没必要再包一层强类型。
第二个坑是忽略模型返回的非法格式。模型不是每次都乖乖返回合法 JSON,有时候会多写一段解释、有时候字段名拼错、有时候干脆返回纯文本。harness 必须能处理这些情况——要么用宽松的解析器,要么把解析失败的信息喂回模型让它重试。我现在的做法是解析失败时,把原始返回和错误信息一起塞回消息历史,让模型自己修正,通常一两次就能对。
第三个坑是沙盒超时没处理好。早期我用简单的timeout包一下,结果发现有些子进程杀了之后还在后台跑,因为子进程又 fork 了孙进程。后来改成杀整个进程组,问题才解决。这个坑很隐蔽,本地测试不容易发现,一上生产就暴露。
第四个坑是trace 打太多导致性能下降。一开始我把每一步的完整消息历史都打进 trace,结果日志量爆炸,序列化本身就成了瓶颈。后来改成只打关键字段和摘要,详细信息按需开启。可观测性要够用就行,不是越多越好。
第五个坑是并发上限设得太乐观。我一开始按模型 API 的速率限制直接设并发数,结果发现工具执行也会消耗资源,两者叠加之后系统扛不住。后来把模型调用和工具执行的并发分开控制,各自留余量,才稳定下来。
8. 关于 harness 工程的一点个人体会
折腾mini-harness这类项目最大的收获,是让我重新理解了 Agent 系统的分层。模型是易变的,今天这个模型强,明天那个模型强,把系统绑死在某个模型上是很危险的。而 harness 是相对稳定的,它处理的是工具调度、状态管理、并发控制这些跟模型无关的问题。把 harness 做扎实,模型怎么换都不慌。
Rust 在这个位置上的优势,用过之后会有体感。编译期把并发问题挡掉,运行时没有 GC 抖动,单二进制部署省心,这些都是实打实的。当然 Rust 的学习曲线在那,写起来没有 Python 那么随手,但 harness 这种底层组件本来就该用更严格的语言写——它一旦出问题,上层全遭殃。
如果你也在做 Agent 相关的东西,我的建议是:先把 harness 这层想清楚,别急着堆功能。工具怎么注册、状态怎么存、并发怎么控、沙盒怎么隔离,这四个问题想明白了,Agent 的上层逻辑怎么写都不会太离谱。反过来,如果这层是糊的,上面功能加得越多,崩得越快。