从零到一写一个 coding Agent

一、引言:Agent 到底是什么

Agent 这个词这两年被炒得很热,但拆开来看,核心逻辑其实非常简单:

Agent = 模型 + 工具 + 循环

更具体地说,是一个叫 ReAct(Reasoning + Acting)的模式:

用户请求 → 模型思考(Thought)→ 调用工具(Action)→ 观察结果(Observation)→ 循环直到给出答案

市面上有很多现成的 Agent 框架,但自己从零写一个的价值在于:你会真正理解每一行代码在干什么,而不是在黑盒上调参数。

这篇文章会沿着一个真实项目的迭代过程,讲清楚写一个 coding Agent 需要解决的几个核心问题:模型怎么接、工具怎么设计、循环怎么控制、安全怎么做、上下文怎么管、记忆怎么存、测试怎么搞。

整个项目最终约 3700 行 Python,16 个模块,27 个测试。不算大,但麻雀虽小五脏俱全。
https://github.com/xinjia-ctrl/miniAgent

二、第一步:模型接入层

Agent 的大脑是 LLM,所以第一件事是把模型接进来。

需要解决什么问题

市面上模型的后端五花八门。OpenAI 是 chat.completions 接口,Anthropic 是 messages 接口,Ollama 又是一套。如果代码里直接写死某一家的 SDK,后面想换模型就得大改。

做法

定义一个抽象基类 BaseClient,统一暴露 chat()chat_stream() 两个方法,输入输出都用自己定义的数据类:

  • AssistantMessage:模型的回复,包含文本内容和工具调用列表
  • ToolCall:工具调用的结构化表示(id、name、arguments)

然后针对每个后端写一个实现类:

  • OpenAIClient:调 openai SDK 的 chat.completions
  • AnthropicClient:调 anthropic SDK 的 messages,还要做消息格式转换
  • OllamaClient:本地模型,走 OpenAI 兼容端点

最后用一个工厂函数 create_backend(config) 根据配置创建对应的客户端。

关键设计点

统一数据类是核心抽象。 不管后端返回的格式多不一样,最终都要转成自己的 AssistantMessageToolCall。这样上层代码(ReAct 循环、上下文管理)完全不用关心后端是谁。

另外,为了测试,还写了一个 FakeModelClient,它不调任何 API,按预设的脚本返回结果。这为后面的确定性测试打下基础。


三、第二步:ReAct 控制循环

模型接进来了,现在要让 Agent “动起来”。

核心逻辑

一个 AgentRuntime 类,负责:

  1. 调模型,拿到回复
  2. 如果回复包含 tool_calls,执行对应的工具函数
  3. 把工具结果追加到消息列表
  4. 再调模型,继续循环
  5. 直到模型给出纯文本回复(没有 tool_calls),返回答案

边界控制

循环不能无限跑下去,需要几个终止条件:

  • 最大步数(默认 15 步):防止 Agent 陷入死循环
  • 重复调用检测:同一轮中出现相同签名(名称+参数)的工具调用直接跳过
  • 结果裁剪:工具返回结果超过 6 万字符时截断

并行执行

读文件、搜索、git 状态这类只读操作没有副作用,可以并行跑。用 ThreadPoolExecutor 把同一轮中的只读工具调用并发执行,减少等待时间。写操作(改文件、跑 shell)则保持串行。


四、第三步:工具系统

Agent 的能力边界由工具决定。工具设计得好不好,直接决定了 Agent 能干多少活。

工具清单

一个 coding Agent 需要的最小工具集:

类别 工具 说明
读取 read_file / read_many_files 按行号范围读文件
搜索 find_files / search_text 按文件名和内容搜索
Git git_status / git_diff 查看仓库状态
编辑 write_file / replace_in_file / apply_patch 增删改文件
执行 run_shell 跑命令
网络 web_fetch 抓网页
记忆 remember / forget_memory 跨会话持久化
委派 delegate 子 Agent 调查

工具注册表

每个工具需要向模型描述自己:叫什么、干什么用、参数有哪些、哪些是必填的。这个描述以 JSON schema 的形式传给模型,模型根据它来生成 tool_calls。

把这些集中管理在一个 tool_registry.py 里,用统一的 _tool() 工厂函数生成 schema,避免散落在各处。

安全设计(最重要)

工具是 Agent 操作真实世界的接口,必须有安全护栏:

  1. 路径锚定:所有文件操作的路径解析后必须在工作区根目录下,../ 逃逸被拦截
  2. 精确替换计数replace_in_file 默认要求 old_text 只出现一次,避免误替换
  3. 批量补丁回滚apply_patch 任一补丁校验失败,已写入的文件自动回滚
  4. 二进制文件检测:读文件时检查前 4KB 是否含空字节,拒绝处理二进制文件
  5. 危险命令识别run_shell 执行前正则匹配是否包含 rm/del/format 等破坏性操作

五、第四步:权限与安全体系

工具安全是"防君子",权限系统才是真正的门禁。

Shell 命令 6 级分类

run_shell 是最危险的工具,不能一刀切。按命令内容自动分级:

等级 示例 说明
read-only dir, type, git status 只读,可自动放行
workspace-write echo >, copy, mkdir 工作区文件变更
git-write git add, git commit Git 写入操作
network curl, pip install 网络访问
shell-write 其他未分类命令 普通命令
destructive rm -rf, git reset, format 破坏性操作,需二次确认

三种权限模式

用户可以选择严格程度:

  • ask:所有工具都询问
  • auto-read(默认):只读工具自动放行,其余询问
  • trusted:非破坏性操作自动放行

秘密脱敏

API Key、Token 等敏感信息不能出现在日志和 trace 里。security.py 中的 redact_obj() 递归遍历字典,替换所有看起来像密钥的字段值为 <redacted>。同时提供 shell_env() 构建受限环境变量,只透传白名单变量给子进程,避免 API Key 泄漏。


六、第五步:上下文管理

Agent 和普通对话的最大区别是:工具结果可能很大(读了个大文件、搜索返回了大量匹配)。上下文窗口是有限的,必须精打细算。

预算分配

总预算 240K 字符,分三段:

  • prefix(6 万):系统指令 + 工作区状态 + 记忆
  • history(16 万):对话历史 + 工具调用链
  • protected(3 万):最新一组消息(当前请求和正在执行的工具链),永远保留

原子性分组

assistant(tool_calls) 消息和后面跟着的 tool 结果消息必须作为一个整体处理——要么一起保留,要么一起丢弃。拆散了模型收到的就是残缺的工具结果,会 confused。

裁剪策略

  1. 先裁 system prefix 里过长的内容
  2. 从最旧的历史组开始丢弃,直到 history 段在预算内
  3. 如果单组过长,裁剪组内消息的 content 字段(不破坏 role/tool_calls 结构)
  4. 如果总预算仍超,继续丢旧组
  5. 最后才压 prefix 到保底额度

最新一组消息(protected)永远不会被丢弃,保证了当前请求和工具调用链的完整性。


七、第六步:记忆系统

Agent 需要记住两件事:当前会话里做过什么,以及跨会话的重要信息

两层记忆

  • 工作记忆:当前会话内的临时状态,存在内存里,会话结束就丢。适合记"正在处理的任务"、“刚才读过的文件”
  • 持久记忆:通过 remember 工具主动存储,存为 JSON 文件,跨会话保留。适合记"用户偏好"、“项目约定”

智能召回

持久记忆用多了以后需要筛选。召回时综合四个维度打分:

  1. 标签匹配(权重 2.0):记忆的 tag 字段和查询词匹配
  2. 关键词匹配(权重 1.5):记忆的关键词列表和查询词匹配
  3. 内容匹配(权重 0.5):记忆的内容文本和查询词匹配
  4. 时效衰减:指数衰减,半衰期约 30 天

分数 = 重要性基础值 + 时效衰减 + 各维度匹配分加权和。

前缀缓存

工作区指纹(git 状态 + 文件内容 SHA-256)和记忆指纹共同作为缓存键。指纹没变就不重建 system prompt,节省 API 调用和 token 开销。


八、第七步:工作区感知

Agent 不能假设"世界静止不动"。用户可能在 Agent 思考的同时改了文件、提交了代码。所以每轮都需要检测外部变化。

工作区快照

每次构建 prompt 前采集:

  • 当前分支
  • git status(未提交变更)
  • 最近 5 条提交
  • 项目文档内容(README.md 等)
  • 项目指令文件(CLAUDE.md 等)

漂移检测

把这些信息算一个 SHA-256 指纹。下轮构建时重新算指纹,如果变了就重建 system prompt,并记录一条 workspace_drift 审计事件。如果没变,直接复用缓存的 prefix。


九、第八步:测试与基准

Agent 测试和普通程序不一样——它依赖 LLM 的输出,结果不确定。所以核心思路是:用假模型做确定性测试,用 benchmark 做量化评估

FakeModelClient

一个脚本播放器,按预设顺序返回结果。比如:

client = FakeModelClient([
    {"tool_calls": [{"id": "c1", "name": "read_file", "arguments": '{"path":"x"}'}]},
    "最终答案",
])

第一次调用返回 tool_call,第二次返回文本。这样整个 ReAct 循环可以在不调用任何 API 的情况下完整跑通。

测试覆盖

最终 7 个测试文件、27 个测试,覆盖:工具函数、运行时循环、权限门禁、秘密脱敏、delegate 隔离、记忆召回、上下文压缩、工作区漂移。

基准测试(Benchmark)

7 个确定性 benchmark,每次运行结果可复现:

  1. 并行顺序:验证并行工具的执行结果顺序正确
  2. 重复调用拦截:验证同一轮中相同签名的工具被跳过
  3. 结果裁剪:验证超长工具结果被截断
  4. 秘密脱敏:验证 API Key 在工具结果中被替换
  5. 上下文压缩:验证超预算消息被正确裁剪
  6. 记忆召回:验证时效衰减下的关键词召回优先级正确
  7. 工作区漂移:验证文件变更后指纹变化

十、项目演进路线

这个项目不是一次性设计出来的,而是经过 10 次提交逐步迭代:

第 1 阶段:CLI 入口 + 基础 ReAct 循环 + 单模型
第 2 阶段:会话持久化 + 上下文预算管理
第 3 阶段:工具扩展(搜索、Git、Web)+ 权限门禁
第 4 阶段:审计日志 + 编辑审批 + 大上下文支持
第 5 阶段:项目结构调整为标准包 + 跨会话记忆
第 6 阶段:用户级配置 + 多后端支持
第 7 阶段:delegate 子 Agent + 安全加固
第 8 阶段:上下文重写 + 秘密脱敏 + 基准框架
第 9 阶段:测试扩展 + 权限细粒度化
第 10 阶段:代码重构 + 模块拆分 + 项目规范

每次迭代都在前一次的基础上加一层能力。这不是巧合——Agent 本身就是渐进式复杂的系统,上来就想"全都要"反而做不出来。


十一、总结与经验

几个最重要的设计决策

  1. 消息级上下文裁剪:在 API message 列表层面做裁剪,保留 role/tool_calls 结构完整性,而不是转成纯文本再裁。这让工具调用链不会被拆散。

  2. 工具权限与模型逻辑分离:权限判断在 runtime 层,不由模型决定。模型可以提议调什么工具,但能不能调是 runtime 说了算。这是安全底线。

  3. 确定性测试先行:没有 FakeModelClient 之前,测试要么要真实 API 调用(慢、贵、不稳定),要么只能测工具函数(测不到 ReAct 循环)。假模型是整个测试体系的基石。


本篇文章没有贴任何代码,给出准确完备的提示词几乎可以直接给出比较优秀的代码。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐