1. 事情的开头:为什么我又造了一个 Agent 轮子
先交代一下背景。我去年大半时间都在折腾 AI Agent 的项目,从最早用 LangChain 拼 Demo,到后来在 Dify 里拖流程,再到现在把 CrewAI 拉进来做多角色协作,基本市面上叫得上名字的框架我都过了一遍。说实话,每个框架都有自己顺手的地方,但越做到后期越别扭,那种感觉就像穿了双大一码的鞋——能走,但跑不起来。
我手上的业务场景比较特殊:不是单纯问答,不是简单提词,而是要让 Agent 真的去"干活"——读文档、查数据库、调内部服务接口、把结果写回工单系统。这类场景对 Agent 的要求不在"会聊天",而在"触达"能力。什么叫触达?就是 Agent 能不能够得着它需要的数据源和工具,够着了之后能不能稳定执行、不丢上下文、不中途崩溃。我查了一圈现成框架,发现问题出在三个地方。
第一个问题是调度笨重。LangChain 的模式偏"链式"思维,一步接一步,但真实的 Agent 任务往往是树状的:主任务拆子任务,子任务之间还有依赖,有的要并行,有的要等结果。用链去硬套,代码里全是条件分支,改一个流程等于重写一遍。
第二个问题是状态管理薄弱。Agent 跑一个复杂任务可能要吃几十轮工具调用,每一轮中间结果都得带着走。现成框架要么把上下文全塞给模型,钱花得飞快,要么自己实现记忆,但一遇到多线程或者重启就丢状态。
第三个问题是接入外部系统的仪式感太重。想调一个内部接口,要先写工具类、再注册、再配描述,一套下来半天没了。我还试过给 PDF 做解析后喂给 Agent,结果格式兼容性让我改到怀疑人生。
这几个痛点叠加,我萌生了自己写一个的想法。当时正好在学 Rust,越学越觉得这套语言和 Agent 运行时的气质很合拍:内存安全、没有 GC 停顿、并发模型干净、单二进制分发方便。于是 "Agent-Reach" 这个项目就开工了。名字里的 Reach 就是核心诉求——让 Agent 真正触达到它需要的每一个工具、每一份数据、每一个外部系统,而不是被困在花哨的提示词里。
可能有人会说,你们这些做技术的就喜欢重复造轮子。但轮子分两种:一种是单纯想造,一种是现有轮子装不到自己车上。我做 Agent-Reach 属于第二种。它不是要替代 LangChain 或 Dify,而是专注解决"Agent 触达外部世界"这一层,而且解决了我自己业务里最疼的几个问题。下面我会把它拆开讲清楚,包括架构思路、记忆设计、实操步骤,以及我在开发过程中踩进去的坑。如果你也正面临"Agent 能聊天但干不了活"的困境,这篇应该能帮上忙。
2. Agent-Reach 的运行时架构:Rust 给 Agent 带来的底气
2.1 核心进程与任务循环
Agent-Reach 的底层是个常驻进程,用 Rust 写的,对外暴露 JSON-over-HTTP 接口,内部跑着一个任务循环。任务循环的职责很简单:从任务队列里拿一个任务,解析目标,决定下一步动作,执行,把结果存回队列,然后进入下一轮。
这个循环是 Agent 的"心跳"。我没有用传统的线程池模型去处理任务,而是选了基于tokio的异步运行时。原因很直接:Agent 的任务大量处于等待状态——等待模型返回、等待外部接口响应、等待用户确认。如果用阻塞线程,一个 Agent 实例同时挂十来个任务,线程数就得上百,系统资源全耗在线程切换上。异步模型下,等待的时候线程是空闲的,可以继续处理其他任务,一个 4 核 8G 的机器,跑几十个 Agent 实例轻轻松松。
任务循环内部维护了一张有向图,节点是"步骤",边是"依赖"。这一步和现成框架的链式结构最大的不同是:依赖关系只规定"谁先谁后",不规定"谁和谁必须同一条路"。A 任务完成后,B 和 C 可以并行发起,D 要等 B、C 都回来。我直接把这张图序列化成了任务状态,存在内存里,每隔几秒做一次周期快照落盘。
这就引出 Agent-Reach 的第一个核心设计:任务状态不是散落在代码变量里,而是有明确的、可序列化的数据结构。每一步的输入、输出、错误信息、 token 消耗都挂在图节点上。好处是崩溃恢复很简单——服务重启后从快照恢复,未完成的任务从头继续,不会出现任务跑到一半忘了自己干到哪的情况。
2.2 工具调用的注册与路由
"触达"的核心在工具调用。Agent-Reach 里,工具的概念被拆成了两层:一层叫能力描述(Declaration),一层叫执行器(Executor)。
能力描述是一段标准化的 JSON,告诉模型"这里有个工具,叫什么、能干什么、需要哪些参数"。执行器是真正干活的函数,接收参数,返回结果。这两层之间通过一个统一的路由表连接。模型在对话里说"我要调用 read_file 工具",Agent-Reach 的调度模块接到这个意图后,到路由表里查 read_file 的声明和执行器,校验参数,执行,拿结果,再决定下一步。
我在设计路由表的时候参考了协议栈的思路。协议栈每一层只关心自己的事——传输层不关心内容是什么,应用层不关心包怎么转发。Agent-Reach 的工具路由也一样:路由层只负责"找到工具、校验参数、转发调用、回收结果",完全不关心工具内部是查数据库还是发 HTTP 请求。这样一来,加一个新工具就是往路由表里注册一个条目,注册入口是几行 Rust 代码或者一个配置文件,不加任何模板代码。
这里我要说一个和现成框架体验差异最大的点。LangChain 里自定义工具要继承类、实现抽象方法、写 docstring,还要手动转成 model 能读的 schema。Agent-Reach 走的是"声明式注册":你在配置文件里写:
{ "name": "search_warehouse", "description": "查询仓库库存,输入商品SKU,返回现有数量", "parameters": { "sku": { "type": "string", "required": true } }, "executor": "http://localhost:9100/invoke/search_warehouse" }路由表收到这个配置,启动时自动拉起声明,不用重启进程。模型侧看到的就是一个干净的函数说明。执行器甚至可以不和 Agent-Reach 跑在同一个进程里——走 HTTP 或内部消息队列都行,我实际部署时就把一批重执行器从主进程剥离了出去,让耗 CPU 的检索逻辑和 Agent 主进程隔离开,一个挂掉不影响另一个。
2.3 基于异步通道的编排
编排这块,我一开始想得很美好:任务图加上异步事件驱动,完美。真实现的时候才发现,"编排"最容易翻车的不是怎么定义步骤,而是怎么处理步骤之间的数据传递。
Agent-Reach 的编排模块用的是"事件总线 + 工作内存"的组合。事件总线有点像消息队列的简化版,只不过跑在进程内部。每个步骤完成之后,往总线上发一个事件,事件里带着结果数据的引用。其他步骤订阅自己关心的事件类型。工作内存则是所有步骤共享的一块状态区,存的是当前任务的"公共记忆"——比如用户原始诉求、已经拿到的关键数据、已经排除掉的错误方案。
这种设计的巧妙之处在于,步骤之间不需要显式声明"我依赖你",而是通过"我关心什么类型的数据"来隐式解耦。B 步骤订阅"库存查询结果"事件,A 步骤只要发了这个事件,B 自然会被触发,不管 A 是哪个工具完成的任务。这样插拔工具非常灵活:今天用内部库存接口,明天换成第三方 ERP,只要事件类型不变,编排层一行都不用改。
等到事件总线跑稳了,我又加了一层"编排策略"的配置文件。上面场景用 BPMN 画图,我这边直接用 YAML 描述:
steps: - id: parse_request tool: extract_intent - id: query_inventory tool: search_warehouse requires: [parse_request] publish: [inventory_result] - id: finalize tool: compose_answer requires: [query_inventory, parse_request]一行一个步骤,依赖关系写得明明白白。这层配置的好处是,业务方自己就能调整任务流程,不用改代码。后来我把这套东西叫做"任务剧本",它成了 Agent-Reach 最受同事欢迎的功能之一。
3. 记忆与上下文:怎么让 Agent 不"失忆"
3.1 短期记忆与工作内存的边界
Agent 跑一个长任务,最大的痛点是"容易失忆"。模型上下文窗口再大,也架不住多轮工具调用之后中间结果把窗口塞满。Agent-Reach 把记忆切成三个层级:短期记忆、长期记忆、外部记忆。短期记忆就是工作内存,负责保存当前任务里还没用到但可能需要用的中间数据。
很多框架在短期记忆上的做法是"把所有对话记录一股脑塞给模型",这是最省事也最烧钱的办法。Agent-Reach 的做法是给记忆设"保质期"。每一条记忆数据打上时间戳和引用计数,只在本任务存活期间有效。任务结束,短期记忆整体清空。执行过程中定期做"记忆压缩"——把已经用过的中间结果摘要成几句话,替代原始内容。比如 Agent 读了 30 页 PDF,工具调用返回的是逐页摘要,模型处理几轮后,短期记忆里只保留"PDF 主要讲了 X 领域的 Y 问题,涉及三个方案",原始长文本块已经不再放在窗口里。
这个压缩策略需要考虑什么时机触发?我在 Agent-Reach 里设了一个阈值:当短期记忆的 token 估算量超过窗口上限的 60%,就触发压缩。压缩不是简单截断,而是调用一个轻量模型把已有记忆做一次归纳总结,然后替换。实测下来,一个原本需要 15 轮对话、窗口几乎爆掉的文档分析任务,压缩机制介入后稳定在 8 轮以内,token 开销省了差不多一半,而且准确率没有明显下降。
3.2 长期记忆与向量化存储
长期记忆解决的是跨任务的积累问题。Agent 做完一个项目,下次遇到类似的项目,应该能想起来上次的经验——这正是 RAG 的用武之地。Agent-Reach 内嵌了一个轻量向量存储模块,默认支持用本地嵌入模型计算向量,不需要额外部署向量数据库。
每次任务结束,Agent-Reach 会把"任务目标 + 执行路径摘要 + 关键结果 + 遇到并解决的问题"固化成一条记忆记录,做向量化写入存储。下一次新任务启动,调度模块会先做一次相似度检索,把 top-3 相关记忆注入到工作内存里,作为参考。
这条机制在重复性业务里价值最大。我手里有个客服工单分类的 Agent,第一次上线时碰到各种刁钻问题,处理得磕磕绊绊。跑了两周后,长期记忆里积累了上百条"怎么处理这种情况"的记录,再遇到新工单,Agent 会直接调出类似 case 的处理路径,准确率从最初的 82% 提到 93%,而人工干预量几乎降到了原来的三分之一。
当然,做长期记忆有一个副作用——记忆污染。如果某条记忆是错误的判断或者一次失败的尝试,它会在后续任务里反复被引用,错误被持续放大。所以 Agent-Reach 对长期记忆做了人工复核接口:记忆写入时默认标记为"未验证",只有被人工审核通过或者被后续成功任务引用两次以上,才会升级为"可信记忆"。
3.3 上下文压缩的代价
吹完优势,也得说代价。上下文压缩不是免费的,它有一个隐性成本:信息丢失。压缩模型对原始内容的归纳可能丢掉一些细节,这些细节在后续步骤里可能突然变得重要。
我遇到过一个真实案例:Agent 在处理一份合同文档时,压缩模块把"违约金比例是 0.5%"这个数字归纳没了,导致后续判断时漏掉了关键条款。排查了半天,最后发现问题是压缩环节丢的。从那以后,我给压缩模块加了一条规则——重要数字、专有名词、日期不允许被归纳替换,必须原样保留。这个规则被写进了记忆压缩的检查清单里。
所以说,上下文压缩是一个需要精细调参的功能,不是开箱即用就完事的。每个业务场景的"哪些信息绝对不能丢"都不一样,Agent-Reach 允许你在配置里自定义保留字段类型,比如金额、日期、合同编号、标的物名称,压缩前自动做标记,标记内容直接跳过归纳环节。
4. 实操:手把手搭一个能自动阅读并汇总文档的 Agent
4.1 环境准备与最小实例
理论说得再多,不如动手跑一个。我来演示一下用 Agent-Reach 搭一个最简单的 Agent:输入一个 PDF 文件路径,Agent 自动读取、解析、提取关键信息、输出一份结构化摘要。这个场景麻雀虽小五脏俱全,能把 Agent-Reach 的触达链路完整走一遍。
环境准备只需要三样东西:Rust 工具链(我用的是 1.75 稳定版)、一个可用的模型 API(OpenAI 兼容接口即可,实测也支持本地部署的模型服务)、以及 Agent-Reach 的二进制。安装我用的是 Cargo:
cargo install agent_reach装完后先初始化工作区:
agent-reach init --workspace ./demo这个命令会生成一个目录骨架,包含config.yaml(主配置)、tools/(工具定义目录)、scripts/(本地执行器脚本目录)。然后启动服务:
agent-reach serve --config ./demo/config.yaml默认监听127.0.0.1:3100。此时 Agent-Reach 已经是一个可以接收任务的运行实体了,只是还没有任何工具——你让它读 PDF,它只会"手一摊"表示做不到。接下来就是给它装"手"。
4.2 定义第一个文档读取工具
PDF 解析在 Agent-Reach 里作为一个外部执行器运行,我用 Python 和pymupdf写了个最简版本:
import fitz, sys, json path = sys.argv[1] doc = fitz.open(path) pages = [] for i, page in enumerate(doc): pages.append({"page": i + 1, "text": page.get_text()}) print(json.dumps({"pages": pages, "total_pages": len(doc)}))然后用 Agent-Reach 的声明式配置把这个脚本挂载成工具。在tools/read_pdf.yaml里写:
name: read_pdf description: "读取一个 PDF 文件,返回按页划分的文本内容。参数为本地文件完整路径。" parameters: file_path: type: string required: true description: "PDF 文件的绝对路径" executor: type: subprocess command: ["python", "./scripts/read_pdf.py", "{file_path}"] timeout_secs: 30存好后刷新工具列表,不用重启服务:
curl -X POST http://127.0.0.1:3100/tools/reload这里{file_path}是模板变量,Agent-Reach 在执行时会把模型传的参数替换进去。为什么我选择用子进程而不是内嵌代码?原因很简单:PDF 解析这种功能有大量现成的库和脚本,改造成子进程工具的成本最低,而且子进程如果崩了不会影响主进程。这种设计把"稳定触达"放在了"优雅集成"前面。
4.3 组合工具链并完成一次完整任务
光能读 PDF 还不够,我想让它"自动阅读并汇总",所以又加了一个extract_key_info统计工具,专门统计全文关键词频率和章节结构。然后走的编辑流程就是改一下任务配置,把 read_pdf 的输出事件作为 extract_key_info 的输入事件。
工具链配好后,发起一个任务:
curl -X POST http://127.0.0.1:3100/tasks \ -H "Content-Type: application/json" \ -d '{ "goal": "阅读 /data/contracts/service_agreement.pdf,输出一份包含签约双方、服务期限、费用结构、违约责任的结构化摘要", "workspace": "./demo" }'返回一个task_id。之后就能轮询任务状态:
curl http://127.0.0.1:3100/tasks/{task_id}任务跑起来后,观察日志能看到完整的执行链路:read_pdf被调用,拿到页面文本放入工作内存,extract_key_info订阅到结果事件后开始统计,模型根据两份中间结果做最终摘要,然后把摘要存入长期记忆,最后任务标记为完成。
第一次跑通这个流程的时候,我盯着日志看了好一会儿,心里想的是:这条链路其实并不复杂,但它是可观测、可恢复、可插拔的。每一步的结果都记录在任务图里,中途哪怕重启服务,任务也能继续。甚至如果我不满意模型的摘要质量,可以直接改提示词配置重新跑,不需要重新读 PDF——因为已经读过的内容还在工作内存里,Agent-Reach 默认开启了步骤级结果复用。
5. 踩坑记:从 "Agent" 到 "Reach" 之间那些坑
5.1 经典的 RPC 错误:empty sid and service name
先说一个我印象特别深的坑,这个坑几乎让我把 Agent-Reach 的整体架构推翻重来。项目做到一半,服务偶尔会在高并发任务下抛一个错误:rpc error (-1): empty sid and service name。每次出现的时间点都不一样,没有任何规律,排查难度拉满。
我一开始以为是网络问题,查了 TCP 连接数、文件描述符、系统端口占用,全部正常。接着怀疑是 HTTP 库的 bug,把 hyper 版本从 0.14 升到 1.0,问题依旧。直到一次偶然的机会,我把日志级别调到 trace,看到错误出现在一条"内部调用"的日志附近——Agent-Reach 内部用 gRPC 做进程间通信,请求服务名称为空。真相大白:某些工具执行器是外部服务,Agent-Reach 通过 gRPC 转发请求,配置里漏了service_name字段,而漏掉的原因是这个字段在配置 schema 里是可选项,默认空字符串。当某个任务走到这条配置不完整的工具时,gRPC 客户端就报了这个让人一头雾水的错误。
修正很简单,把配置 schema 里service_name改为必填,并在启动时校验。但这个坑给我两个教训:第一,配置校验必须前置,不能在运行时才发现;第二,报错信息要把内部上下文带出来,否则"空 sid 和 service name"这种错误对用户来说完全是天书。Agent-Reach 后来的所有错误都带上了工具名和配置片段,排查效率直线提升。
5.2 Rust 异步生命周期与共享状态的纠缠
Rust 写业务逻辑,最大的门槛是借用检查器。Agent 任务循环天然要共享状态——工作内存、路由表、记忆存储都要在异步任务之间传来传去。我第一版代码用Arc<Mutex<HashMap>>硬怼,能跑,但锁竞争非常严重,任务一多性能就直线下滑。
后来我换成了 actor 模型:每个核心模块(工作内存、事件总线、路由表)是一个独立的 actor,拥有自己的状态,只通过消息通道和其他 actor 通信。状态不再共享,自然没有锁。Rust 的tokio::sync::mpsc和oneshot通道正好满足这个需求。这个改动带来的额外收益是模块边界清晰了——工作内存的读写职责明确了,不再从代码各处直接修改共享 Map。
这个坑对所有想用 Rust 写 Agent 框架的人都适用:不要幻想用 Java 那套"大对象共享 + 同步锁"的方式来组织状态,actor 模型和 Rust 的 ownership 模型简直是天作之合。绕开锁竞争之后,单实例吞吐量差不多翻了一倍。
5.3 工具调用超时与指数退避
另一个实战里被反复锤出来的问题是超时处理。Agent 调用外部接口,对方的响应速度是不可控的。我最初统一设了 15 秒超时,实际跑起来发现,很多外部系统在高峰期响应要 30 秒甚至更久,而有些接口一旦超过 5 秒基本就是死掉了。一刀切的超时策略非常不科学。
Agent-Reach 后来的超时机制是"分级 + 重试":每个工具的 timeout 在配置里独立指定(timeout_secs),重试策略默认指数退避,第一次 1 秒,第二次 2 秒,第三次 4 秒,最多重试 3 次。对于读 PDF 这种本地子进程,超时设为 30 秒比较稳妥;对于查库存这类内部接口,超时 5 秒、重试 2 次就够了。更重要的是,超时和重试的日志里必须带上"当时用的是哪个工具、什么参数、第几次重试",不然排查时根本不知道是哪一环慢。
6. 安全边界:Agent 触达真实世界前的最后一道闸
6.1 沙箱与权限拆分
Agent 的工具能"触达",就必然引入安全问题。一句话总结:Agent 能触达的范围越大,系统暴露的风险面就越广。Agent-Reach 在安全上做了几层设计,我觉得值得单独拿出来讲。
第一层是执行沙箱。所有子进程工具默认在受限环境里运行,用系统用户nobody(Linux 下)跑,目录访问限制在工作区范围内,网络访问默认关闭,只有显式声明allow_network: true的工具才具备外联能力。这一条曾经救过我——一次误配置导致 Agent 被诱导去读取系统敏感文件,因为沙箱权限限制,读取动作被拒绝,任务记录里留下一条拒绝日志。
第二层是权限拆分。Agent-Reach 的 API 分为管理面和任务面:管理面负责工具注册、配置修改、记忆审核,需要管理员 token;任务面只接受任务创建和状态查询,用普通 token 即可。这个拆分防止了"拿到 Agent 入口就能改配置"的越权风险。尤其在暴露到公网环境时,管理面接口我会彻底关闭,只保留任务面放在防火墙后面。
6.2 审计日志与可追溯性
安全不光是防住,还要能追溯。Agent-Reach 从设计的第一天就带完整的审计日志:每一次工具调用、每一个被注入到上下文的外部数据、每一次任务状态变更,全部记录到结构化日志里。日志不光有时间和动作,还有任务的完整上下文 ID,方便把一次任务的所有操作串起来。
我做了一个"操作回放"的小功能:给定任务 ID,可以按时间线重放这个 Agent 当时做了什么、调了哪些工具、拿到了什么结果、模型是怎么决策的。这个功能在排查问题和争议处理时特别好用。有一次客户投诉说 Agent 给了一个错误的库存数字,我用回放功能看到 Agent 调用了一个已经退役的查询接口,返回的是旧数据,才定位到是工具注册表里没及时清理过期的执行器。如果没有审计日志,这种问题只能靠猜。
关于 Agent 安全,我还想多说一句:很多人觉得安全是最后才考虑的,但 Agent 这种"自主行动"的系统,安全必须前置。Agent-Reach 的做法是在架构层就把安全边界画好——沙箱、权限、审计不是一个插件,而是运行时的内生能力。宁可前期多花点时间在这上面,也不要等出了事故再补防火墙。
最后的几点体会
Agent-Reach 从最初一个"解决自己业务痛点"的小工具,慢慢长成了一个让我自己都意外的项目。回看这段开发经历,我最大的体会不是 Rust 怎么写、架构怎么搭,而是"触达"这两个字的分量。Agent 的能力上限不在模型有多聪明,而在它能稳定、安全、可追溯地触达多少真实世界的东西。模型负责思考和决策,但手和脚,是框架给的。
如果你也在做 Agent 相关的工作,有几个建议送给你。不要一上来就上全家桶框架,先想清楚你的 Agent 到底需要触达哪些东西,列一张清单;给 Agent 的每个工具都设计好超时、重试和审计,这不是可有可无,是底线;还有,别迷信大模型上下文窗口,记忆管理和上下文压缩虽烦,但省下的真金白银会让你庆幸做了。
最后分享一个小技巧:Agent 任务的中间结果尽量缓存起来。Agent-Reach 里我默认开启步骤级结果复用,同一个任务重跑时,步骤只要输入没变,就直接用上次的结果,不再调用工具。一个读 30 页 PDF 并分析的任务,重跑时间从 40 秒压到 3 秒,而且省了一轮模型的 token。这种"抠细节"的地方,往往才是 Agent 工程化能不能落地的关键。