自建AI终端助手:从Agent闭环到工具调用的完整实践

📅 发布时间:2026/10/14 21:57:32
自建AI终端助手:从Agent闭环到工具调用的完整实践
说实话这两年终端AI助手这类东西火得有点出乎我意料。尤其是某开源项目OpenClaw出来之后大家发现原来大模型不只可以聊天、写代码还能帮你直接操作电脑扫描文件、执行命令、查资料、整理目录就像给模型装了一双手。我也跟着折腾了一段时间但直接拿现成的项目来用总有种穿着别人鞋子跑步的感觉——默认行为不合口味想改就得动源码改着改着发现还不如从零写一套来得清爽。所以我一跺脚自己写了一个类似OpenClaw的AI终端助手起名叫“龙虾AI终端助手”开源并且含完整源码。这篇文章不聊虚的把我整个设计思路、核心模块实现、源码阅读顺序、以及实测两个月踩过的坑全部拆开讲清楚希望能帮你少走点弯路。1. 项目定位为什么我选择自建而不是直接照搬1.1 这类终端助手到底做了什么先讲清楚一个基本概念普通的AI聊天助手是“问答式”的你问它“当前目录下有多少个图片文件”它只能告诉你“你可以打开文件夹自己看”而终端AI助手的核心是“智能体”形态也就是Agent。它不只是回答你而是真的会去执行调文件列表、统计扩展名、算数量然后把结果告诉你。这个区别听起来不大但体验完全是两回事。这类终端的核心逻辑是一个闭环模型先理解你的意图决定需要调用哪个工具Agent拿到这个决定后执行对应的本地函数执行产生的结果再塞回给模型模型基于新信息继续判断一直到它觉得可以给出最终答案为止。OpenClaw正是把这一整套东西做成了开箱即用的产品我做的龙虾也是围绕这个闭环来的。1.2 我的取舍原则哪些该抄哪些该自己写有人肯定会问既然有现成开源方案为什么还要自己重复造轮子我的核心考量有三条。第一是学习成本。Agent循环、工具注册、上下文裁剪、流式输出这些概念看文档是一回事自己动手写一遍是另一回事。我把整个闭环从零实现了一遍之后再回去看那些开源项目理解完全不是一个量级。第二是定制自由度。现成项目为了照顾大多数用户会在安全策略、默认指令、交互格式上做很多预设。当你的使用习惯跟预设不一致时改动的成本可能比维护一套自己的代码还高。自己写的代码想加一个插件、换一个模型平台都是几十分钟的事。第三是安全边界。终端助手拥有本地执行权限这本身就值得谨慎。自己维护源码意味着每一条本地命令的执行逻辑都在你的把控之下二次确认机制、日志记录、权限白名单都能按自己的标准来。但我也要说不是所有轮子都要重新发明。终端界面布局、配置解析、SQLite存储这类通用能力我直接用了成熟的第三方库和系统组件没有自己造。自建的核心部分是Agent逻辑和工具调度这玩意儿才是灵魂。2. 把终端助手拆成五层架构从输入到行动的完整链路2.1 模块划分与数据流我一开始写代码的时候也是想到哪写到哪后来发现不行Agent这个东西涉及的状态太多了不做好分层后面会改到崩溃。最终我把整个项目拆成了五个模块CLI交互层负责用户的输入输出包括多行输入、流式打印、快捷键处理。模型接入层统一封装各家大模型接口对外只暴露一个简洁的chat方法。Agent核心层维护多轮对话状态跑决策循环决定什么时候调用工具。工具注册中心管理所有可用工具的定义和实现支持插件动态加载。配置与记忆层读配置文件、管理会话历史、持久化到本地数据库。这五层的数据流是这样的用户输入先经过CLI交互层交给Agent核心层Agent把历史消息和工具定义一起发给模型接入层模型返回一个“行动指令”或者“最终回答”如果是行动指令Agent解析后去工具注册中心找到对应的函数执行执行结果追加进上下文再进入下一轮循环。整个过程有点像一个循环车间模型负责下决策工具负责干活。2.2 为什么让“模型不执行代码只输出JSON”这是整个设计里最核心的决策。为什么不让模型直接执行代码因为大模型本质上是个文本生成器它生成的那串代码能不能跑、跑起来会不会删库没有人能保证。所以我采用的做法是模型只负责输出一个结构化指令描述它想调用哪个工具、参数是什么真正的代码由Agent进程执行。这种设计的最大好处是可控。工具的执行权在本地模型永远接触不到操作系统API它可以“建议”调用某个工具但有没有权限、要不要二次确认都由Agent来决定。同时模型也不需要关心工具内部是怎么实现的它只需要知道工具的名字、作用、参数格式。这大大降低了模型的决策难度也让整个系统更好调试——任何一个动作都可以在日志里被完整还原。举个直观的例子用户输入“统计当前目录下的图片数量”模型返回的决策可能是这样的{ thought: 用户需要统计文件数量我应该先列出目录下的文件, tool: list_files, parameters: { path: . } }Agent收到这串JSON之后才会真正去执行list_files这个函数然后把文件列表写回上下文。模型继续基于这个结果决定下一步是做筛选还是直接统计。整个链路里模型始终只负责“想”动手的是Agent。2.3 工具描述为什么用JSON Schema工具注册中心里的每个工具除了对应的实现函数之外还必须带一份结构化的“说明书”。我用的就是JSON Schema格式描述这个工具是干什么的、参数有哪些、每个参数的类型和含义是什么。模型能不能正确调用工具很大程度上取决于这份说明书写得清不清楚。因为模型是通过“文字理解”来匹配工具调用的description写得越具体、参数设计得越合理模型就越不容易乱来。比如一个工具叫search_files如果描述只写“搜索文件”模型可能什么时候都想不起来用它但如果写清楚“搜索指定目录下文件名包含关键字的文件返回文件路径列表可用于定位文件”模型就会在合适场景主动调用。这个细节看着不起眼实际影响巨大。3. 四个关键模块的落地细节交互、模型、循环与工具3.1 CLI交互层好看只是表面交互才是灵魂终端助手的交互层不能只是一个简单的readline。我用的是基于文本界面库的方案把界面切成上下两个区域上方是输出区负责展示模型回复和工具执行日志下方是输入区支持多行输入和快捷键操作。这个体验跟普通终端命令完全不同更像是一个跑在终端里的聊天应用。交互层最容易翻车的是流式输出。大模型生成回复是一段一段来的如果等全部生成完再一次性打印用户盯着屏幕等十几秒没有任何反馈体验会非常差。我的做法是模型接口走流式事件流每回传一个字符片段就立即刷新到输出区。这样用户能看到文字像打字机一样逐字出现等待感会弱很多。另外一个关键点是CtrlC的行为。终端应用里用户按CtrlC直觉是中断当前生成而不是退出程序所以我把中断信号单独拦截下来正在生成的流式任务会被取消但会话和输入历史都保留。3.2 模型接入层一套接口对接多个模型平台不同模型平台的接口协议差异很大有的走自家SDK有的兼容通用协议参数命名、返回格式都不一样。如果业务代码直接依赖某一家平台那以后想换模型就要动一堆地方。所以我加了一个模型接入层把所有平台的差异挡在外面。我定义了一个统一的消息结构roler、content再加上可选的tool_calls字段。所有对话内容都转成这种结构传给模型层模型层再把它翻译成各平台自己的格式。每个平台写一个适配器实现同一个chat方法接收历史消息和工具列表返回模型结果。这样Agent核心层完全不关心底层是哪个模型平台切换模型只需要改配置文件和适配器。配置文件长这样[model] provider provider_a api_key_env AI_API_KEY model_name smart-chat-latest stream true [agent] max_iterations 8 history_turns 20 auto_confirm falsekey通过环境变量注入不进配置文件这个习惯我从第一天就坚持了。命令行工具很容易被截图分享密钥一旦写进配置就等于裸奔。3.3 Agent核心循环“想一下动一下再看一下”Agent核心层是整个项目的心脏实现并不复杂但细节全在边界条件里。核心循环的逻辑可以用一段伪代码说清楚history load_history() for step in range(max_iterations): result llm.chat( messageshistory, toolsget_all_tool_schemas() ) if result.has_tool_calls(): history.append(result) observations execute_tools(result.tool_calls) history.append_observations(observations) else: final_answer result.content break这个循环看起来简单但有两个坑是必须处理的。第一个坑是死循环。模型在复杂任务里可能会反复调用某个工具永远不给出最终答复白白消耗token。我设置了最大迭代轮数默认8轮。一旦超过这个轮数Agent会强制让模型给出一个基于现有信息的回答即使不完整也先收尾。第二个坑是上下文膨胀。每执行一次工具工具结果就会写回上下文多轮下来历史消息很容易超过模型的上下文窗口。我用了两层策略保留最近20轮完整对话更早的历史压缩成一段摘要放进系统提示词里。这个策略在长会话里实测效果很好既保留了关键信息又不会把上下文撑爆。3.4 工具注册中心让龙虾长出手脚工具注册中心的设计目标是“加一个新功能不用改核心代码”。每个工具包含三样东西名字、schema说明书、实现函数。我用装饰器来做注册代码结构是这样的TOOL_REGISTRY {} def register_tool(schema): def decorator(func): TOOL_REGISTRY[schema[name]] { schema: schema, handler: func } return func return decorator register_tool({ name: list_files, description: 列出指定目录下的所有文件支持递归, parameters: { type: object, properties: { path: {type: string, description: 要列出的目录路径}, recursive: {type: boolean, description: 是否递归子目录默认false} }, required: [path] } }) def list_files(path: str, recursive: bool False) - str: # 实现略 return file1.txt\nfile2.md内置工具我第一批做了八个文件列表、目录创建、文件读取、文件搜索、打开URL、执行受限命令、读取环境信息、计时器。这些工具基本覆盖了日常高频场景。安全边界上我定了三条硬规矩第一所有工具执行日志完整记录方便回溯第二危险操作走二次确认比如删除文件、执行系统命令都需要用户在终端里输y确认第三命令执行默认在受限模式下运行只开放一部分只读命令其他命令需要单独配置白名单。这三条规矩下来系统可用性会稍微减弱一点但安全冗余度提高很多我觉得值。4. 手把手把龙虾跑起来部署、读源码与自定义插件4.1 环境准备与首次启动项目基于Python开发要求版本在3.10以上。先建虚拟环境再装依赖这两步是老规矩我习惯每一步都确认状态python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt依赖装完之后第一步是配置模型密钥。你需要先把模型平台的API密钥设置到环境变量里项目启动时会读取这个变量。然后复制项目里的默认配置文件改成你自己的模型平台和模型名称。启动非常简单python main.py如果一切正常你会看到一个带输入区的终端界面在输入框里输入“你好”模型会正常回复。到这一步说明模型接入层和CLI交互层已经通了一半。接下来可以试试带工具调用的任务比如输入“当前目录下有哪些文件”观察上方输出区会出现工具执行日志然后再出现最终回答。看到工具日志的那一刻说明Agent循环已经完整跑通了。4.2 源码阅读路线别一上来就啃全部代码很多人拿到源码第一反应是从main.py开始逐行读结果读着读着就迷失在细节里。我给一个推荐的阅读顺序先看入口再看核心最后看功能。源码结构是这样的lobster_assistant/ ├── main.py # 命令行入口初始化所有模块 ├── agent/ │ ├── core.py # Agent循环核心中的核心 │ └── context.py # 上下文管理历史裁剪与摘要 ├── model/ │ ├── client.py # 模型接入层统一chat接口 │ └── schemas.py # 消息结构定义 ├── tools/ │ ├── registry.py # 工具注册与调度 │ └── builtin.py # 内置工具实现 ├── config/ │ └── settings.py # 配置加载与环境变量读取 ├── storage/ │ └── history_store.py # 会话历史数据库操作 └── plugins/ # 插件目录按需加载 └── weekly_report.py我的建议是第一步读main.py只需要知道程序是怎么启动的、五层模块是怎么串起来的。第二步跳到agent/core.py把Agent循环那一小段读懂这是整个项目的灵魂。第三步看model/client.py理解统一接口是怎么屏蔽不同平台差异的。最后再回来看tools/registry.py这时候你会发现注册中心其实特别简单就是一张“名字到函数”的映射表。按这个顺序读快的话半小时就能把项目框架摸清楚。4.3 实战扩展给龙虾加上“生成周报”插件光跑通还不够得亲手加一个工具才能真正理解这套架构的用法。我这里拿一个实际场景举例每周五想写工作总结但不想打开编辑器一个字一个字敲能不能让龙虾帮我读取本周的Git提交记录再让模型整理成周报需求明确了实现只需要三步。第一步在plugins目录下新建一个weekly_report.py。第二步写一个函数负责调git命令把最近的提交历史抓出来。第三步用register_tool注册成工具。核心代码如下import subprocess from tools.registry import register_tool def get_git_commits(days: int 7) - list[str]: cmd [ git, log, f--since{days} days ago, --pretty%ad %s, --dateformat:%m-%d ] result subprocess.run(cmd, capture_outputTrue, textTrue) commits [line.strip() for line in result.stdout.strip().split(\n) if line.strip()] return commits register_tool({ name: generate_weekly_report, description: 获取最近N天的git提交记录用于生成周报素材, parameters: { type: object, properties: { days: { type: integer, description: 回溯天数默认7 } } } }) def generate_weekly_report(days: int 7) - str: commits get_git_commits(days) return \n.join(f- {item} for item in commits)代码写完后重启程序直接输入“帮我生成这周的周报”。龙虾会先调用generate_weekly_report拿到提交记录然后基于这份材料生成带格式的周报内容。整个过程你可以在输出区清楚看到“调用工具 - 返回结果 - 模型生成总结”的完整链路。这里有个小经验插件函数返回的字符串不要太长尤其不要把大文件全文塞进去。模型上下文窗口有限工具返回结果太大会把后面的对话空间挤没。周报插件里如果提交记录上百条最好先截断或聚合成统计信息再交给模型加工。5. 实测两个月整理的踩坑记录与排查方法5.1 高频问题速查表这两个月里我遇到的大大小小的问题不少挑高频的整理成了一张表方便你按图索骥现象定位思路解决方式模型半天不调用工具只输出建议工具描述写得太模糊模型不知道何时该用重写description明确触发场景和参数含义对话超过十轮后明显变卡上下文累积太长超出模型处理上限调低history_turns开启历史摘要压缩流式输出一卡一卡不连贯网络往返延迟高或者没真正开启流式检查模型平台是否支持流式确认stream配置为true执行本地命令权限不足受限模式默认只开放只读命令在白名单里补充必要的命令重启生效插件加载后不生效文件命名或装饰器注册没被扫描到确认插件在plugins目录下模块被自动导入工具执行成功后模型仍然重复执行工具结果没正确写回上下文模型看不到检查返回值是否用append_observations加入历史5.2 三个让我印象深刻的Bug复盘第一个是死循环问题。有一次我让龙虾“查一下当前项目里哪些文件超过1MB”它居然连续调用了六次list_files每次都在不同的目录里越找越远完全没有要停下来的意思。最后排查发现两个原因一是工具描述里没有限制搜索范围模型无法判断任务该在哪里停二是当时max_iterations设成了10给了它太多“自由发挥”的空间。后来我把默认轮数改成8并且在描述中明确要求“只查找当前目录不需要递归子目录”问题立刻消失。第二个是上下文爆掉的问题。我尝试让龙虾分析一个大型日志文件直接把整个文件内容读进了提示词结果很快触发上下文限制。后来我改成对大文件做预处理先读取文件截取前200行做一个预览摘要统计行数和关键错误码出现次数再把摘要传给模型。这样既保留了信息又把token占用控制在了合理范围。通用原则是能不塞原始数据就不塞能塞摘要就不塞全文。第三个是流式输出的渲染竞态。刚开始实现时网络回包线程直接把内容推送到终端UI刷新里结果高并发片段下来时界面会出现字符错乱甚至闪退。排查后把方案改成网络线程只负责把文本片段放进一个带锁的队列UI主线程再从队列里消费并渲染。这个改法虽然多了一层但彻底避免了数据竞争。5.3 体验优化让龙虾更顺手工具能跑通只是第一步日常用起来是否舒心取决于一些细节优化。延迟这块我优先选响应速度快的模型来做终端交互场景模型能力稍微弱一点没关系因为大部分重活可以直接通过工具完成模型主要负责决策和总结不需要在生成长文本上花太多时间。记忆这块除了历史摘要压缩之外我还把用户的常用偏好直接写进了系统提示词比如“代码风格偏好用中文注释”、“默认终端宽度保持120列”。模型在生成回答时会自动遵循这些偏好不用每轮对话重复交代。最后是首次启动的引导体验。第一次运行的时候我会在输出区打印一段简短的说明把最常用的几个命令示例列出来比如“输入ls可以触发文件列表”、“输入help查看内置工具清单”。这一步看着不起眼但能让新手上手成本降低一大截也方便别人试用你的开源项目时快速进入状态。我个人在实际操作中的体会是写这个项目的最大收获不是代码本身而是把Agent的“决策-行动-观察”闭环彻底想通了。工具调用、上下文管理、插件机制这些概念听一百遍都不如自己跑通一遍来得深刻。后面我还想继续扩展语音输入和图形界面让龙虾从纯终端走向更通用的场景但核心闭环这套设计不需要大变。最后分享一个小技巧给工具写description的时候把自己当成第一次拿到这个工具的人把触发场景、参数含义、返回值都写清楚模型就跟你一样说明书越清楚活儿干得越准。