1. 从零构建终端 AI 编程智能体的整体设计思路
1.1 为什么要在终端里做 AI 编程智能体
终端是开发者最高频的工作界面,绝大多数编码、调试、构建、部署动作最终都要落到命令行上。把 AI 编程能力直接嵌进终端,而不是再开一个网页或独立 IDE 插件,最大的好处是上下文天然连续:你正在哪个目录、刚跑了什么命令、上一条报错是什么,终端里的智能体都能直接感知,不需要你手动复制粘贴一堆信息过去。
我最初做这个项目的动机很朴素:日常写代码时,大量时间花在“查一个命令怎么写”“这段报错什么意思”“帮我改一下这个配置文件”这类碎片化任务上。每次切到浏览器或另一个窗口,思路都会被打断。于是我想做一个能常驻终端、随时唤起的编程智能体,它要能读当前项目文件、能执行命令、能根据执行结果继续推理,而不是一个只会聊天的问答框。
这个项目适合几类人参考:一是想理解 AI 智能体底层运作机制的开发者;二是想给自己团队做内部编码助手的工程师;三是对“上下文工程”这个概念感兴趣、想知道它落地到底长什么样的人。哪怕你只是想搞清楚一个智能体从接收指令到调用工具再到返回结果这条链路,这篇文章里的拆解也能直接用上。
1.2 整体架构:脚手架、运行时、工具层三层分离
我把整个系统拆成三层,这个划分是踩过坑之后定下来的,不是一开始就想清楚的。
脚手架层负责项目初始化、依赖管理、配置加载、命令注册。它的职责是让智能体“能跑起来”,包括解析用户输入、加载模型配置、初始化工具集。这一层要尽量薄,因为它最容易随需求变化而膨胀。
运行时层是核心循环,负责把用户输入、历史对话、工具执行结果组装成模型能理解的上下文,调用模型,解析模型返回的动作,再决定是继续调用工具还是输出最终答案。这一层是整个智能体的“大脑调度中心”。
工具层是智能体与外界交互的手脚,包括文件读写、命令执行、代码搜索、目录遍历等。每个工具都是一个独立函数,有明确的输入输出契约。
三层分离的好处是:换模型只动运行时,加工具只动工具层,改交互方式只动脚手架。我试过把三者揉在一起写,结果加一个新工具要改五六个地方,维护成本高得离谱。
1.3 技术选型背后的取舍逻辑
语言上我选了 Python,原因很直接:生态里模型调用库、文件处理库、命令行框架都最成熟,原型迭代速度最快。如果你追求极致启动速度,可以考虑 Go 或 Rust,但开发效率会打折扣,对个人项目来说不划算。
模型接入层我做了抽象,用一个统一的LLMClient接口包住不同提供方的差异。这样做的原因是模型迭代太快,今天用这个明天可能换那个,如果业务代码里到处散落着具体 SDK 调用,迁移时会非常痛苦。抽象层只需要暴露chat(messages, tools)这一个方法,内部处理鉴权、重试、流式解析。
命令行框架我用了argparse加自定义的子命令分发,没有上更重的框架。因为智能体的交互模式比较特殊——它需要持续对话而不是一次性执行,重型 CLI 框架的很多特性用不上,反而增加理解成本。
提示:技术选型不要追求“最先进”,要追求“改动成本最低”。智能体项目最大的特点就是需求变化快,选型时优先考虑可替换性。
2. 脚手架搭建与测试环境准备的核心细节
2.1 项目目录结构与依赖管理
目录结构我反复调整过几版,最终稳定成下面这样:
agent/ cli/ # 命令行入口与参数解析 core/ # 运行时循环、上下文组装 tools/ # 各类工具实现 llm/ # 模型客户端抽象 config/ # 配置加载与校验 tests/ # 测试用例 sandbox/ # 测试用隔离环境这个结构的关键在于core和tools严格分离。core只依赖工具的抽象接口,不依赖任何具体工具实现。这样测试运行时循环时,可以注入假工具,不用真的去读写文件或执行命令。
依赖管理我用pyproject.toml加虚拟环境。核心依赖只有三个:模型调用库、pydantic做数据校验、rich做终端渲染。我刻意控制依赖数量,因为每多一个依赖就多一个版本冲突风险。曾经因为引入了一个功能很全但依赖树庞大的库,导致环境装了三十多个包,后来全部砍掉自己写了精简版。
2.2 测试环境为什么要做隔离
智能体会执行命令、读写文件,如果直接在开发机上跑测试,轻则污染工作目录,重则误删重要文件。我踩过一次坑:测试一个删除文件的工具时,路径拼接出了 bug,结果删掉了项目里一个源码目录。从那以后我强制要求所有涉及文件系统和命令执行的测试必须在隔离环境里跑。
隔离方案我用的是临时目录加子进程。每个测试用例启动时创建一个临时目录,把智能体的工作目录指向它,测试结束后整个目录删掉。命令执行则通过子进程在临时目录里跑,并设置超时和资源限制。
import tempfile import subprocess from pathlib import Path def run_in_sandbox(command: str, timeout: int = 10): with tempfile.TemporaryDirectory() as tmpdir: result = subprocess.run( command, shell=True, cwd=tmpdir, capture_output=True, text=True, timeout=timeout, ) return result.stdout, result.stderr, result.returncode这段代码看起来简单,但有几个细节值得说。shell=True方便但危险,生产环境要谨慎;timeout必须设,否则一个死循环命令能把测试挂死;capture_output保证输出被捕获而不是直接打到终端。
2.3 配置加载与密钥管理
配置我分成两层:一层是项目级配置,放在项目根目录的配置文件里,定义模型、超时、工具开关等;另一层是环境变量,专门放密钥这类敏感信息。两者合并时环境变量优先级更高,方便在不同环境下覆盖。
密钥绝对不能写进代码或提交到版本库。我的做法是启动时从环境变量读取,读不到就报错退出,而不是给一个默认值。给默认值是很多安全事故的源头,因为开发者本地能跑,上线后忘了配环境变量,结果用了默认密钥。
配置校验用pydantic的模型来做,每个字段有类型和范围约束。比如超时必须是正数,模型名称必须非空。校验失败时给出明确错误信息,而不是让程序跑到一半才崩。
注意:配置项不要设计得太多。我一开始加了二十多个配置项,结果自己都记不住哪个是干嘛的。后来砍到八个核心项,其余全部用合理默认值,维护负担立刻下降。
3. 上下文工程:智能体真正的核心竞争力
3.1 上下文窗口是稀缺资源
很多人以为智能体的能力取决于模型多强,实际做下来我发现,上下文组装的质量比模型本身更关键。模型再强,如果你塞给它一堆无关信息,它也会被带偏;反过来,一个中等模型配上精准的上下文,表现往往超出预期。
上下文窗口是有限资源,每一条消息都在消耗预算。系统提示词、历史对话、工具定义、工具执行结果、当前用户输入,全都要塞进这个窗口。我的策略是分级管理:系统提示词和工具定义是固定开销,必须精简;历史对话按重要性裁剪;工具执行结果做截断和摘要。
系统提示词我改了十几版,从最初的一千多字压缩到三百字左右。删掉的全是“你是一个专业的助手”这类废话,只保留行为约束和输出格式要求。字数少了,模型反而更听话,因为关键指令没有被淹没。
3.2 历史对话的裁剪策略
对话历史不能无限增长,必须裁剪。我试过三种策略:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 滑动窗口 | 只保留最近 N 轮 | 实现简单 | 早期关键信息丢失 |
| 摘要压缩 | 把旧对话总结成一段 | 保留信息 | 摘要本身消耗模型调用 |
| 重要性打分 | 按相关性保留 | 精准 | 实现复杂,打分不准 |
最终我用的是滑动窗口加关键信息锚点。滑动窗口保留最近若干轮完整对话,同时把用户最初的任务描述、已经确认的关键结论作为锚点固定保留。这样既控制了长度,又不会丢掉任务目标。
裁剪的时机也有讲究。我是在每次调用模型前检查 token 数,超过阈值才裁剪,而不是每轮都裁。这样能减少不必要的模型调用,也避免频繁裁剪导致上下文抖动。
3.3 工具执行结果的压缩与呈现
工具执行结果是上下文膨胀的主要来源。一次命令执行可能返回几千行输出,全塞进去直接爆窗口。我的处理是:默认只保留前若干行和后若干行,中间用省略标记;如果输出里有明显的错误信息,优先保留错误相关行。
def truncate_output(text: str, head: int = 50, tail: int = 20) -> str: lines = text.splitlines() if len(lines) <= head + tail: return text omitted = len(lines) - head - tail return "\n".join( lines[:head] + [f"... 省略 {omitted} 行 ..."] + lines[-tail:] )这个函数看着简单,但head和tail的取值有讲究。头部通常包含命令回显和起始输出,尾部通常是结果和错误,中间大量是重复的进度信息。我实测下来 50 加 20 这个组合对大多数场景够用。
另外,工具结果里要带上退出码和是否超时这类元信息,模型需要这些来判断命令是否成功。只给输出不给状态,模型会误判。
3.4 让模型“知道自己在哪”
一个容易被忽略的点是环境感知。智能体需要知道当前工作目录、操作系统、可用的命令等。这些信息我放在系统提示词里动态生成,而不是写死。
比如当前目录,我会在每轮对话开始时注入一条环境信息,告诉模型现在在哪个路径下。这样模型生成文件路径时就不会凭空捏造。操作系统信息也很重要,因为同一个命令在不同系统上行为不同,模型需要据此调整。
提示:环境信息要放在上下文的靠前位置,且每轮更新。放在末尾容易被模型忽略,放在开头能持续影响它的判断。
4. 运行时循环与工具调用的实操实现
4.1 核心循环的完整流程
运行时循环是整个智能体的心脏,它的逻辑是:接收输入,组装上下文,调用模型,解析动作,执行工具,把结果塞回上下文,再调用模型,直到模型输出最终答案或达到最大轮数。
def run_agent(user_input: str, max_turns: int = 10): messages = build_initial_context(user_input) for turn in range(max_turns): response = llm_client.chat(messages, tools=tool_schemas) if response.is_final: return response.content for action in response.actions: result = execute_tool(action.name, action.args) messages.append(build_tool_result_message(action, result)) return "达到最大轮数限制,任务未完成"这段伪代码省略了很多细节,但主干就是这样。max_turns是必须的,否则模型可能陷入无限循环。我设的默认值是 10,大多数任务够用,复杂任务可以调高。
4.2 工具定义与参数校验
每个工具都要有清晰的名称、描述和参数 schema。描述要写清楚这个工具干什么、什么时候用、有什么限制。模型是根据描述来决定调不调用的,描述写得含糊,模型就会乱调。
参数校验我用pydantic做,模型给的参数先过一遍校验,不合法就直接返回错误信息给模型,让它重新生成。这样比让非法参数进入工具实现再报错要安全得多。
from pydantic import BaseModel, Field class ReadFileArgs(BaseModel): path: str = Field(..., description="要读取的文件路径") max_lines: int = Field(100, ge=1, le=1000, description="最多读取行数") def read_file(args: ReadFileArgs) -> str: p = Path(args.path) if not p.exists(): return f"错误:文件不存在 {args.path}" lines = p.read_text(encoding="utf-8").splitlines() return "\n".join(lines[:args.max_lines])注意max_lines有上限约束,防止模型要求读取一个超大文件把上下文撑爆。这类防御性设计在工具层非常必要。
4.3 命令执行工具的安全边界
命令执行是最强大也最危险的工