1. 为什么我要自己撸一个 Agent-Reach
先说结论:Agent-Reach 是我在过去几个月里反复折腾出来的一个命令行工具,核心目标只有一个——让 AI Agent 真正能"伸手"够到外部世界。你可能已经用过不少 Agent 框架,它们能思考、能规划、能调用大模型,但一到"帮我查一下今天某个接口返回了什么"、"把这个目录下的日志按规则过滤一遍"、"自动跑一遍测试并汇总结果"这类活儿,就开始抓瞎。原因很简单:大部分 Agent 的"手"太短,只能在自己那套沙箱里打转。
Agent-Reach 要解决的就是这个"最后一公里"的问题。它本质上是一个基于 Python 构建的 CLI 工具,把常见的系统操作、网络请求、文件处理、命令执行这些能力封装成 Agent 可以直接调用的"工具集",再通过一套轻量的调度层把大模型的决策和实际执行串起来。你可以把它理解成给 AI Agent 装了一双能伸到终端、文件系统和网络里的手。
这篇文章适合谁看?如果你正在搭 AI Agent,卡在"怎么让它真的干活"这一步;或者你是个 Python 开发者,想搞明白 Agent 的工具调用到底怎么落地;再或者你只是对 CLI 工具感兴趣,想看看一个能跑起来的 Agent 项目长什么样——那这篇内容应该能给你不少可直接抄的作业。我会把设计思路、核心实现、踩过的坑、排查问题的套路都摊开讲,尽量做到你看完就能自己复现一个简化版。
2. 整体架构设计与技术选型拆解
2.1 为什么是 CLI 而不是 Web 服务
很多人一上来就想搞个 Web 界面,觉得那样才"像个产品"。我一开始也这么想,后来发现完全走偏了。Agent-Reach 的核心用户是开发者自己,使用场景是本地开发、调试、自动化脚本。这种场景下 CLI 的优势太明显了:
- 启动成本极低:一条命令就能跑,不需要起服务、配端口、处理跨域。
- 和现有工作流无缝衔接:可以直接塞进 shell 脚本、CI 流程、crontab。
- 调试直观:输出直接打在终端上,日志、错误、中间结果一目了然。
- 权限模型简单:本地跑就是本地权限,不用额外设计一套鉴权。
Web 服务不是不能做,而是不该是第一版就做。我见过太多项目在还没跑通核心逻辑的时候就开始堆前端,最后核心能力一塌糊涂。Agent-Reach 坚持 CLI 优先,等核心稳定了再考虑包一层服务。
2.2 Python 作为主语言的理由
热词里 Python 出现频率极高,这不是偶然。Agent-Reach 选 Python 做主力语言,主要基于这几点考量:
第一,生态成熟。无论是 HTTP 请求(requests、httpx)、文件处理(pathlib、shutil)、还是进程管理(subprocess、psutil),Python 都有现成且稳定的库。自己造轮子纯属浪费时间。
第二,和大模型 SDK 的亲和度高。主流的大模型调用库对 Python 的支持都是第一梯队的,接口稳定、文档齐全、社区案例多。你不太可能遇到"这个功能只有 Java 版有"的尴尬。
第三,上手门槛低。Agent 这个领域现在大量是个人开发者在玩,Python 能让更多人快速参与进来。虽然 Rust 在性能和并发上有优势,但对于一个以"调度和编排"为主的工具来说,Python 的性能完全够用,开发效率反而更重要。
当然,我也在关键路径上做了一些优化。比如并发执行工具调用时,用的是asyncio而不是多线程,避免 GIL 带来的额外开销。对于 CPU 密集型的子任务,会考虑丢给子进程或者外部命令处理。
2.3 核心分层:决策层、调度层、执行层
Agent-Reach 的内部结构我拆成了三层,这个划分是踩了不少坑之后定下来的:
决策层负责和大模型交互,把用户输入、当前上下文、可用工具列表打包成 prompt,拿到模型返回的工具调用意图。这一层不关心工具怎么执行,只关心"要调什么、传什么参数"。
调度层是中间枢纽,负责解析模型的返回、校验参数、决定并发还是串行、处理超时和重试、把执行结果回传给决策层。这一层是整个项目最复杂也最容易出问题的地方。
执行层就是一个个具体的工具实现,每个工具是一个独立的函数或类,有明确的输入输出契约。执行层不关心是谁调用的,只负责把活干好。
这么分层的好处是:换模型只动决策层,换工具只动执行层,调度逻辑可以独立测试。我试过把三层揉在一起写,结果就是改一处崩三处,维护成本爆炸。
2.4 工具调用的协议设计
工具怎么描述、怎么调用,这个协议设计直接决定了整个系统的可用性。Agent-Reach 用的是类似 OpenAI function calling 的 JSON Schema 描述方式,每个工具定义包含:
{ "name": "read_file", "description": "读取指定路径的文件内容,支持文本文件", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件绝对路径"}, "encoding": {"type": "string", "default": "utf-8"} }, "required": ["path"] } }这个描述会随 prompt 一起发给模型,模型根据描述决定调不调、怎么调。描述写得好不好,直接影响到模型能不能正确使用工具。我踩过的坑是:描述太简略,模型经常传错参数类型;描述太啰嗦,又浪费 token 还干扰判断。后来总结出一个原则——描述里必须包含"这个工具干什么"和"参数什么含义",但不要写实现细节。
3. 核心模块的细节实现与实操要点
3.1 工具注册机制:让 Agent 知道有哪些手可用
工具注册是 Agent-Reach 的入口。我设计了一个装饰器风格的注册方式,用起来很直观:
from agent_reach import tool @tool(name="read_file", description="读取文本文件内容") def read_file(path: str, encoding: str = "utf-8") -> str: with open(path, "r", encoding=encoding) as f: return f.read()这个装饰器做了几件事:把函数签名解析成 JSON Schema、把函数注册到全局工具表、保留原始函数供执行层调用。用装饰器的好处是工具定义和实现在一起,不会出现"描述和实现对不上"的情况。
这里有个细节值得说:参数类型注解必须写全。Python 是动态类型语言,但工具调用需要明确的类型信息。我要求所有工具函数的参数都必须有类型注解,装饰器会检查这一点,缺了就直接报错。这个约束一开始觉得麻烦,后来发现它避免了大量运行时才暴露的参数错误。
注意:工具函数的返回值建议统一成字符串或可 JSON 序列化的结构。如果返回复杂对象,调度层序列化时容易出问题,而且模型也不一定看得懂。
3.2 调度层的并发控制:Agent 怎么扛并发
热词里"ai agent 怎么扛并发"是个高频问题,这也是 Agent-Reach 调度层的核心挑战。Agent 执行过程中经常需要同时调用多个工具,比如同时读三个文件、同时请求两个接口。如果串行执行,整体耗时就是各步骤之和;并发执行能大幅压缩时间。
我用的是asyncio.gather配合信号量控制并发数:
import asyncio async def execute_tools(tool_calls, max_concurrency=5): semaphore = asyncio.Semaphore(max_concurrency) async def run_one(call): async with semaphore: return await execute_single(call) results = await asyncio.gather( *[run_one(c) for c in tool_calls], return_exceptions=True ) return results为什么要加信号量?因为无限制并发会打爆系统资源。我实测过,同时发起 50 个文件读取请求,磁盘 IO 直接飙满,整个进程卡死。把并发数控制在 5 到 10 之间,既能提速又不会把机器搞崩。
return_exceptions=True这个参数也很关键。默认情况下gather遇到一个异常就会取消其他任务,但 Agent 场景下我们希望"一个工具失败不影响其他工具",所以要让异常作为结果返回,由调度层统一处理。
3.3 超时与重试:别让一个卡住的工具拖垮全局
工具执行超时是必须处理的。网络请求可能卡住、外部命令可能挂起、文件读取可能遇到超大文件。Agent-Reach 给每个工具调用都设了超时,默认 30 秒,可以在工具定义里覆盖:
@tool(name="http_get", description="发起 HTTP GET 请求", timeout=10) def http_get(url: str) -> str: ...超时用asyncio.wait_for实现:
try: result = await asyncio.wait_for(execute_single(call), timeout=call.timeout) except asyncio.TimeoutError: result = {"error": "工具执行超时"}重试策略我做得比较克制。只对幂等的工具做重试,比如读取文件、GET 请求。对于有副作用的操作(写文件、POST 请求),默认不重试,避免重复执行造成数据问题。重试次数默认 2 次,间隔用指数退避,避免瞬间重试把下游打挂。
3.4 上下文管理:Agent 的记忆怎么存
Agent 执行多轮任务时,上下文会越来越长。如果不加控制,很快就会超出模型的上下文窗口。Agent-Reach 的上下文管理做了两件事:
一是结果截断。工具返回的结果如果太长,会截断到指定长度(默认 4000 字符),并在末尾标注"结果已截断"。这个阈值可以根据模型窗口调整。
二是历史压缩。当对话轮次超过阈值时,把早期的工具调用和结果压缩成摘要。压缩用的是模型本身,让模型把"做了什么、得到什么关键信息"提炼出来,丢弃冗余细节。
实操心得:截断阈值不要设得太小。我一开始设成 1000 字符,结果模型经常因为看不到完整结果而做出错误判断。后来调到 4000,效果好很多。如果你的模型窗口够大,可以放到 8000。
4. 从零搭建一个可运行的 Agent-Reach
4.1 环境准备与依赖安装
先把基础环境搭起来。Python 版本建议 3.10 以上,因为用到了asyncio的一些新特性。安装依赖:
pip install httpx pydantic richhttpx:异步 HTTP 请求,比 requests 更适合并发场景。pydantic:参数校验和序列化,工具调用的参数校验全靠它。rich:终端输出美化,调试时看日志舒服很多。
如果你要用大模型,还需要装对应的 SDK。这里不绑定具体厂商,Agent-Reach 的决策层做了抽象,换模型只需要改一个适配器。
4.2 核心调度循环的实现
整个 Agent 的主循环逻辑其实不复杂,核心就是"模型决策 → 执行工具 → 回传结果 → 再决策"这个循环:
async def run_agent(user_input: str, max_turns: int = 10): messages = [{"role": "user", "content": user_input}] for turn in range(max_turns): response = await call_model(messages, tools=get_all_tools()) if not response.tool_calls: return response.content messages.append(response.to_message()) results = await execute_tools(response.tool_calls) for call, result in zip(response.tool_calls, results): messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result) }) return "达到最大轮次限制,任务未完成"max_turns这个限制很重要。我遇到过模型陷入死循环,反复调用同一个工具,如果没有轮次上限,程序会一直跑下去烧 token。设成 10 轮对大多数任务够用,复杂任务可以调大。
4.3 一个完整的工具实现示例
拿"读取目录下所有日志文件并过滤关键字"这个场景举例,实现一个组合工具:
from pathlib import Path from agent_reach import tool @tool(name="grep_logs", description="在指定目录的日志文件中搜索关键字") def grep_logs(directory: str, keyword: str, pattern: str = "*.log") -> str: dir_path = Path(directory) if not dir_path.is_dir(): return f"错误:{directory} 不是有效目录" matches = [] for log_file in dir_path.glob(pattern): try: content = log_file.read_text(encoding="utf-8", errors="ignore") for i, line in enumerate(content.splitlines(), 1): if keyword in line: matches.append(f"{log_file.name}:{i}: {line.strip()}") except Exception as e: matches.append(f"{log_file.name}: 读取失败 - {e}") if not matches: return f"未找到包含 '{keyword}' 的日志" return "\n".join(matches[:100])这个工具里有几个细节:errors="ignore"处理编码问题,避免因为个别乱码字符导致整个文件读不了;结果限制 100 条,防止返回内容过长;异常被捕获后作为结果返回,不会中断整个 Agent 流程。
4.4 参数校验与错误处理
工具执行前必须校验参数。用 pydantic 做校验,把 JSON Schema 转成模型类:
from pydantic import BaseModel, ValidationError class ReadFileParams(BaseModel): path: str encoding: str = "utf-8" def validate_params(tool_name: str, params: dict): model = PARAM_MODELS.get(tool_name) if not model: return params, None try: validated = model(**params) return validated.dict(), None except ValidationError as e: return None, f"参数校验失败:{e}"校验失败时,把错误信息作为工具结果回传给模型,模型看到错误后通常会自己修正参数重试。这个机制让 Agent 有了一定的"自我纠错"能力。
注意:错误信息要写得具体,告诉模型哪个参数错了、期望什么类型。我试过只返回"参数错误",模型完全不知道该怎么改,只能瞎猜。
5. 常见问题排查与避坑实录
5.1 模型不调用工具怎么办
这是最常见的问题。模型明明有能力调工具,但就是直接回答,不调。排查思路:
先看工具描述是不是太模糊。如果描述写的是"处理文件",模型不知道具体能干什么,就不会调。改成"读取指定路径的文本文件内容并返回",意图就清晰了。
再看系统提示词。提示词里要明确告诉模型"你有工具可用,遇到需要外部信息的任务优先调用工具"。我一开始没写这句,模型经常自己编答案。
最后看模型本身。有些小模型对 function calling 的支持不好,换个大一点的模型试试。这不是 Agent-Reach 的问题,是模型能力问题。
5.2 工具调用参数总是传错
参数传错通常有三个原因:类型注解缺失、描述不清、模型理解偏差。对照检查:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 传了字符串但期望数字 | 类型注解缺失 | 补全类型注解 |
| 参数名拼错 | 描述里没写清参数名 | 描述中明确列出参数名 |
| 必填参数没传 | required 没标 | 检查 Schema 的 required 字段 |
| 传了多余参数 | 模型自由发挥 | 校验层拒绝未知参数 |
我踩过最坑的一次是参数名用了缩写,模型总是猜错。后来统一改成完整单词,问题就没了。
5.3 并发执行时的资源竞争
多个工具同时写同一个文件、同时改同一个状态,就会出现竞争。Agent-Reach 的处理方式是:对有副作用的工具加锁。用一个全局的asyncio.Lock保护写操作:
write_lock = asyncio.Lock() @tool(name="write_file", description="写入文件内容") async def write_file(path: str, content: str) -> str: async with write_lock: Path(path).write_text(content, encoding="utf-8") return f"已写入 {path}"这样即使多个写操作并发发起,实际执行也是串行的,避免内容互相覆盖。
5.4 上下文爆炸导致模型失智
对话轮次多了之后,上下文越来越长,模型开始"忘事"或者做出莫名其妙的判断。这是上下文窗口被塞满的典型症状。解决办法前面提过,就是截断和压缩。但还有个技巧:把关键信息固定在系统提示词里。比如任务目标、重要约束,每轮都带上,不依赖模型从历史里回忆。
5.5 常见问题速查表
| 问题 | 排查方向 | 快速修复 |
|---|---|---|
| Agent 不干活 | 工具描述、系统提示词 | 补全描述,加引导语 |
| 参数传错 | 类型注解、Schema | 补注解,加校验 |
| 执行超时 | 工具耗时、网络 | 调大超时,加重试 |
| 结果太长 | 截断阈值 | 调小阈值或压缩 |
| 死循环 | max_turns | 设轮次上限 |
| 并发崩溃 | 并发数、资源 | 加信号量限流 |
6. 一些实操心得和后续扩展方向
跑通 Agent-Reach 之后,我在实际使用中最大的体会是:Agent 的能力上限不取决于模型多聪明,而取决于工具设计得多好。同样一个模型,工具描述清晰、参数设计合理,它就能干出漂亮的活;工具设计得乱七八糟,再强的模型也白搭。
另一个心得是关于调试。Agent 的执行链路很长,出问题时很难定位是哪一环。我的做法是在每一层都打详细日志,尤其是调度层,把"收到什么调用、校验结果、执行耗时、返回什么"全记下来。用rich打印带颜色的日志,一眼就能看出哪一步卡住了。
后续可以扩展的方向不少。比如加一个工具市场,让社区贡献工具;比如支持多 Agent 协作,一个负责规划一个负责执行;比如把执行层做成插件式,支持动态加载。这些都不难,核心架构已经留好了扩展点。
最后分享一个小技巧:如果你想让 Agent 处理特定领域的任务,与其写一堆通用工具,不如针对这个领域写几个高度专用的工具。工具越专用,模型越容易用对,效果越好。这个原则我在好几个项目里验证过,屡试不爽。