1. 为什么我要自己撸一个 Agent-Reach
先说清楚这东西是干嘛的。Agent-Reach 是我最近折腾出来的一个命令行工具,核心定位就一句话:让 AI Agent 能真正“够得着”外部世界。你如果用过各种大模型接口,肯定遇到过这种尴尬——模型本身很聪明,但它被困在对话框里,没法读你本地的文件、没法查数据库、没法调你写好的脚本。Agent-Reach 就是来解决这个“最后一公里”问题的。
我平时的工作流里,Python 脚本一大堆,有些是数据清洗的,有些是调内部接口的,还有些是定时跑的分析任务。以前每次想让 AI 帮我串起来,都得手动复制粘贴上下文,效率极低。后来我试过不少现成的 Agent 框架,要么太重,要么绑定特定平台,要么配置复杂到劝退。于是干脆自己动手,用 Python 写了一个轻量的 CLI 工具,把“AI 决策”和“本地执行”这两层彻底打通。
这个项目适合谁呢?如果你满足下面任意一条,那这篇内容应该对你有用:写过 Python 但没怎么碰过 AI Agent 的;想给自己日常脚本加个“智能调度层”的;对 CLI 工具有执念、喜欢在终端里完成一切的;或者单纯想看看一个 Agent 项目从零到跑起来到底要踩哪些坑。我会把设计思路、核心代码逻辑、实操步骤、以及我调试过程中遇到的各种幺蛾子都摊开讲,尽量让你看完就能复现一个自己的版本。
2. Agent-Reach 的整体设计与思路拆解
2.1 核心架构:三层分离,各司其职
Agent-Reach 的架构我改过三版,前两版都因为耦合太严重被我推倒重来了。现在稳定下来的结构是三层:接入层、决策层、执行层。这个分层不是拍脑袋定的,而是被实际需求逼出来的。
接入层负责跟大模型对话,把用户的自然语言指令转成结构化的“行动计划”。决策层拿到计划后,判断每一步该调用哪个本地能力,比如读文件、跑脚本、查数据。执行层就是真正干活的,调用 Python 的 subprocess、文件 IO、或者第三方库去完成任务。三层之间通过统一的 JSON 消息格式通信,这样任何一层想换实现,都不会影响另外两层。
为什么这么分?因为我一开始把模型调用和文件操作写在一个函数里,结果想换个模型供应商的时候,发现要改几十处代码。分层之后,换模型只需要动接入层的一个适配器文件,执行层完全不用碰。这个教训很值钱:Agent 项目的复杂度不在于单个功能,而在于功能之间的组合爆炸,分层是控制复杂度的唯一手段。
2.2 为什么选 CLI 而不是 Web 界面
很多人问我,都什么年代了还做 CLI?我的理由很实在。第一,我的使用场景 90% 在终端里,写代码、跑脚本、看日志都在终端,切到浏览器去跟 AI 对话再切回来,这个上下文切换成本太高。第二,CLI 天然适合管道操作,我可以把 Agent-Reach 的输出直接 pipe 给 grep、jq 或者另一个脚本,组合能力是 Web 界面给不了的。第三,开发成本低,不用折腾前端框架和部署,一个人维护完全扛得住。
当然 CLI 也有代价,比如交互体验不如图形界面直观,复杂任务的进度展示比较粗糙。我的取舍是:优先保证核心链路的稳定和可组合性,交互体验够用就行。如果你要做的是面向非技术用户的产品,那 Web 界面可能更合适,但如果是给自己或者小团队用的效率工具,CLI 的性价比高得多。
2.3 技术选型:Python 为主,关键路径考虑过 Rust
主体用 Python,这个没什么好纠结的。生态成熟,写起来快,调试方便,而且我的大部分本地脚本本来就是 Python 写的,直接复用。但我在设计执行层的时候认真考虑过用 Rust 重写关键模块,原因是并发场景下 Python 的 GIL 确实是个瓶颈。
具体来说,当 Agent 需要同时调用多个外部工具时,Python 的多线程在 CPU 密集型任务上基本没优势。我实测过,用 threading 跑 10 个并行的数据处理任务,总耗时跟串行差不多,因为 GIL 把真正的并行锁死了。后来我改用 asyncio 加多进程的组合:IO 密集的用 asyncio,CPU 密集的丢给 ProcessPoolExecutor。这个方案在大多数场景下够用,但如果你的 Agent 要扛很高的并发,比如同时处理上百个请求,那确实需要考虑用 Rust 写一个高性能的执行引擎,通过 FFI 或者子进程的方式跟 Python 主逻辑通信。
我目前的判断是:先用 Python 把逻辑跑通,等真的遇到性能瓶颈再针对性优化。过早用 Rust 重写,开发效率会掉一大截,而且很多性能问题其实通过架构调整就能解决,不一定非要换语言。
3. 核心细节解析与实操要点
3.1 消息协议设计:Agent 各层之间的“普通话”
三层之间怎么通信,这个协议设计是整个项目的地基。我最终定下来的格式是一个 JSON 对象,包含action、params、context三个顶层字段。action是字符串,标识要做什么,比如read_file、run_script、query_db。params是参数字典,具体内容由 action 决定。context用来传递会话状态,比如上一步的输出、用户的原始指令等。
# 消息协议示例 { "action": "run_script", "params": { "script_path": "/home/user/scripts/clean_data.py", "args": ["--input", "raw.csv", "--output", "clean.csv"] }, "context": { "session_id": "abc123", "step": 2, "previous_output": "数据加载完成,共 10000 行" } }这个设计的关键在于action 和 params 的分离。一开始我把所有东西都塞进一个扁平字典里,结果执行层要写一大堆 if-else 来判断参数含义,非常丑陋。分离之后,执行层只需要一个 action 到处理函数的映射表,每个处理函数只关心自己的 params 结构,代码清晰了不止一个档次。
注意:context 字段不要塞太多东西,我踩过的坑是把整个对话历史都放进去,结果消息体积膨胀到几 MB,序列化和传输都成了瓶颈。后来改成只传必要的状态摘要,历史记录单独存到本地 SQLite 里,需要的时候再查。
3.2 模型接入层的适配器模式
接入层我用的是适配器模式,每个模型供应商对应一个适配器类,统一实现parse_intent和generate_response两个方法。这样换模型的时候,只需要写一个新的适配器,注册到工厂里就行。
class BaseAdapter: def parse_intent(self, user_input: str) -> dict: """把自然语言转成结构化行动计划""" raise NotImplementedError def generate_response(self, result: dict) -> str: """把执行结果转成自然语言回复""" raise NotImplementedError class AdapterFactory: _adapters = {} @classmethod def register(cls, name: str, adapter_cls): cls._adapters[name] = adapter_cls @classmethod def get(cls, name: str) -> BaseAdapter: return cls._adapters[name]()这里有个实操要点:parse_intent 的提示词工程是重中之重。我试过直接让模型输出 JSON,结果它经常在 JSON 外面包一层 markdown 代码块,或者加一些解释性文字,导致解析失败。后来我在提示词里明确要求“只输出 JSON,不要任何其他内容”,并且在解析前加了一个清洗步骤,用正则把代码块标记去掉。这个清洗逻辑虽然丑,但非常实用,建议你也加上。
3.3 执行层的安全边界
执行层是最危险的地方,因为它真的会跑命令、删文件、改数据。我给自己定了三条铁律:第一,所有文件操作限制在指定的工作目录内,用os.path.realpath做路径校验,防止../穿越。第二,所有 shell 命令必须白名单,只允许执行预注册的脚本,不允许模型直接生成任意命令。第三,所有写操作先备份,改文件之前自动复制一份到.backup目录。
import os ALLOWED_SCRIPTS = { "clean_data": "/home/user/scripts/clean_data.py", "gen_report": "/home/user/scripts/gen_report.py", } def run_script(script_name: str, args: list): if script_name not in ALLOWED_SCRIPTS: raise PermissionError(f"脚本 {script_name} 不在白名单中") script_path = ALLOWED_SCRIPTS[script_name] # 后续执行逻辑...这三条铁律帮我避免了好几次事故。有一次模型理解错了指令,想执行一个删除操作,因为不在白名单里直接被拦下来了。Agent 的安全不能靠模型自觉,必须靠代码层面的硬约束,这是我从实际踩坑中得出的血泪教训。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭起来。我假设你已经装好了 Python,版本建议 3.10 以上,因为用到了 match-case 语法和一些新的类型注解特性。如果还没装,去 Python 官网下载安装包,安装时记得勾选“Add Python to PATH”,这个选项能省掉后面很多配置麻烦。
依赖方面,核心就几个:openai或者你用的模型供应商的 SDK、rich用来做终端输出美化、pydantic做数据校验。用 pip 一条命令搞定:
pip install openai rich pydantic如果你在国内,pip 下载慢的话,可以临时指定镜像源:
pip install openai rich pydantic -i https://pypi.tuna.tsinghua.edu.cn/simple提示:建议用虚拟环境,别直接装在系统 Python 里。
python -m venv agent-env然后激活,这样依赖隔离,不会污染其他项目。
4.2 项目骨架搭建
目录结构我建议这样组织,清晰且易于扩展:
agent-reach/ ├── main.py # CLI 入口 ├── adapters/ # 模型适配器 │ ├── __init__.py │ └── openai_adapter.py ├── core/ │ ├── __init__.py │ ├── dispatcher.py # 决策层,负责路由 │ └── executor.py # 执行层,负责干活 ├── utils/ │ ├── __init__.py │ └── logger.py └── config.yaml # 配置文件main.py是入口,用argparse或者click解析命令行参数。我用的argparse,因为标准库自带,不用额外装依赖。核心逻辑是:接收用户输入,交给 dispatcher,dispatcher 调用 adapter 解析意图,然后路由到 executor 执行,最后把结果返回给用户。
4.3 核心调度逻辑实现
dispatcher 是整个项目的大脑,它的职责是协调 adapter 和 executor。我实现的时候用了简单的状态机:IDLE -> PARSING -> EXECUTING -> RESPONDING -> IDLE。每个状态转换都有对应的日志记录,方便排查问题。
class Dispatcher: def __init__(self, adapter, executor): self.adapter = adapter self.executor = executor self.state = "IDLE" def handle(self, user_input: str) -> str: self.state = "PARSING" plan = self.adapter.parse_intent(user_input) self.state = "EXECUTING" results = [] for step in plan.get("steps", []): result = self.executor.execute(step) results.append(result) if result.get("status") == "error": break self.state = "RESPONDING" response = self.adapter.generate_response({"results": results}) self.state = "IDLE" return response这里有个细节值得说:执行过程中如果某一步失败,我是直接中断还是继续?我的选择是默认中断,因为 Agent 的任务通常有依赖关系,前一步失败后面继续跑可能产生错误结果。但我在配置里留了continue_on_error开关,某些独立任务可以开启继续执行。
4.4 参数计算与性能调优
并发处理是 Agent 绕不开的话题。我实测过几种方案,这里给一组具体数据。测试场景是同时处理 20 个文件读取加分析任务,每个任务耗时约 0.5 秒。
| 方案 | 总耗时 | CPU 利用率 | 内存占用 |
|---|---|---|---|
| 纯串行 | 10.2s | 15% | 80MB |
| threading | 9.8s | 18% | 95MB |
| asyncio | 1.2s | 25% | 110MB |
| asyncio + 多进程 | 0.8s | 65% | 320MB |
结论很清晰:IO 密集型任务用 asyncio 收益最大,CPU 密集型任务必须上多进程。我最终的方案是混合模式,dispatcher 根据任务类型自动选择执行策略。判断逻辑很简单:如果任务涉及网络请求或文件 IO,走 asyncio;如果涉及大量计算,丢给进程池。
import asyncio from concurrent.futures import ProcessPoolExecutor async def execute_io_task(task): # IO 密集型,用 asyncio await asyncio.sleep(0) # 模拟异步操作 return await do_io_work(task) def execute_cpu_task(task): # CPU 密集型,用多进程 with ProcessPoolExecutor() as pool: future = pool.submit(do_cpu_work, task) return future.result()注意:多进程方案下,任务函数必须是可序列化的,不能是 lambda 或者闭包。我一开始用 lambda 传参,直接报 PicklingError,排查了半天才发现是这个原因。改成顶层函数或者类的静态方法就好了。
5. 常见问题与排查技巧实录
5.1 模型输出格式不稳定怎么办
这是最高频的问题。模型有时候输出纯 JSON,有时候包代码块,有时候加解释文字。我的解决方案是三层防护:第一层,提示词里明确格式要求并给示例;第二层,解析前用正则清洗;第三层,解析失败时自动重试并加强提示。
import json import re def parse_model_output(text: str) -> dict: # 清洗代码块标记 text = re.sub(r'^```(?:json)?\s*', '', text.strip()) text = re.sub(r'\s*```$', '', text) try: return json.loads(text) except json.JSONDecodeError: # 尝试提取第一个 JSON 对象 match = re.search(r'\{.*\}', text, re.DOTALL) if match: return json.loads(match.group()) raise实测下来,加了清洗逻辑之后,解析成功率从 70% 左右提升到 95% 以上。剩下 5% 的失败案例,重试一次基本都能解决。
5.2 执行超时与资源泄漏
Agent 调用外部脚本时,如果脚本卡死,整个流程就挂了。我给每个执行步骤加了超时控制,默认 30 秒,超时直接 kill 进程并返回错误。同时用try-finally确保临时文件和数据库连接一定会被清理。
import subprocess def run_with_timeout(cmd: list, timeout: int = 30): try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout ) return {"status": "ok", "output": result.stdout} except subprocess.TimeoutExpired: return {"status": "error", "message": f"执行超时({timeout}秒)"} finally: # 清理逻辑 pass5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 模型返回空结果 | API 配额耗尽或网络问题 | 检查 API 响应状态码 | 增加重试机制,检查账户余额 |
| JSON 解析失败 | 模型输出格式不规范 | 打印原始输出查看 | 加强提示词,增加清洗逻辑 |
| 脚本执行报权限错误 | 文件权限或路径问题 | 检查文件权限和路径 | 用绝对路径,检查 chmod 设置 |
| 并发任务互相干扰 | 共享状态未隔离 | 检查全局变量和临时文件 | 每个任务用独立的工作目录 |
| 内存持续增长 | 资源未释放 | 用 memory_profiler 分析 | 确保连接和文件句柄及时关闭 |
5.4 我踩过的三个大坑
第一个坑是路径问题。我在开发机上用相对路径跑得好好的,部署到服务器上就找不到文件了。原因是工作目录变了。后来我强制所有路径都用os.path.abspath转成绝对路径,问题消失。这个坑很低级,但真的很常见。
第二个坑是编码问题。Windows 上默认编码是 GBK,Linux 上是 UTF-8,我处理中文文件的时候在两边表现不一致。解决方案是显式指定encoding='utf-8',所有文件读写都加上这个参数,一劳永逸。
第三个坑是模型幻觉。有一次模型编了一个不存在的脚本名,执行层直接报错。后来我在执行层加了白名单校验,不在白名单里的操作一律拒绝。永远不要相信模型输出的内容,所有关键操作都要有代码层面的校验,这是做 Agent 项目的铁律。
6. 后续扩展方向与个人体会
Agent-Reach 目前跑通了我最核心的需求,但还有不少可以打磨的地方。我接下来想做的几个方向:一是加一个简单的 WebSocket 接口,让其他程序也能调用 Agent 的能力;二是把执行层的工具注册做成插件化,通过配置文件动态加载,不用改代码就能加新工具;三是加一个任务队列,支持长时间运行的任务异步执行,完成后通知。
如果你也想自己撸一个类似的工具,我的建议是从最小可用版本开始,别一上来就追求大而全。我第一版只支持读文件和跑脚本两个功能,代码不到 200 行,但已经能解决我 80% 的问题。后面加的功能都是在这个基础上逐步迭代出来的。Agent 这个领域变化很快,保持代码的灵活性和可替换性,比追求一步到位重要得多。
另外,多看看别人的 Agent 项目是怎么设计的,但别照搬。每个项目的使用场景不一样,适合别人的架构不一定适合你。我的三层分离架构就是参考了几个开源项目之后,根据自己的实际需求调整出来的。理解设计背后的“为什么”,比记住“怎么做”更有价值。