我最近一直在折腾一件事用 DeepSeek 搭一个真正能自己动手干活的 AI coding agent。不是那种在 IDE 里陪你聊天的问答式插件而是让它自己读代码、定位报错、改文件、跑测试、再修问题直到任务闭环。折腾完一圈把整套思路、代码、踩过的坑都整理成这篇文章给想自己动手做原生 AI 编程代理的同学一份可复现的参考。1. 先想清楚原生 AI coding agent 到底做的是什么1.1 不是聊天机器人而是闭环执行者很多人一提到AI 编程就想到 ChatGPT 那个对话框——你把代码贴进去它给你一个修改建议你再自己复制粘贴回去。这套玩法其实是AI 辅助问答离真正的 coding agent 还差着两层。真正的 coding agent 是让模型处在一个闭环里它要有能力感知当前代码库的状态要能主动调用工具读文件、搜字符串、跑命令要看执行结果来决定下一步动作然后继续循环。我给你举个例子任务是帮我修一个挂掉的单元测试。问答式 AI 只会告诉你可能是这里的问题建议在 xxx 加个判断。而我想要的行为是agent 自己找到最新的测试失败日志用 grep_search 定位相关源码读取被测函数发现问题后直接写文件修复然后重新跑测试。如果测试还是红它继续阅读输出、定位下一层原因再修改直到测试变绿。这中间的差异就是给建议和干活的差异。做一个能真正干活的 agent核心不是堆更多模型能力而是把感知、决策、行动、验证这四步闭环跑通。DeepSeek 给我的感受是它的推理能力和工具调用稳定性让这个闭环在工程实践里真正成为可行方案。1.2 为什么强调原生原生这个词被各种营销稿件用烂了但在我这里它有两个非常具体的含义。第一模型本身原生支持 function calling函数调用不需要我在 prompt 里写请以 JSON 格式输出再靠正则去解析。传统上大家让模型输出结构化指令用的是 prompt 硬引导写一大段你必须输出 {command: ...}然后代码去字符串里扒。这种方法又脆又蠢模型稍微换个语气、加句解释解析就直接崩。DeepSeek 的 API 提供了原生的 tools 参数和 tool_calls 返回结构模型输出格式由接口保证代码里直接取字段用就行这才是原生集成的底气。第二agent 的执行链路是原生的中间没有套壳平台。我见过不少团队用AI 编程助手是把代码库传到一个第三方平台平台在远端跑 agent然后把结果同步回来。这样做确实省事但代码主权、数据隐私、自定义工具的灵活性全部让渡出去了。我自己搭的方案是文件读写、目录遍历、命令执行全部在本地环境里完成模型只收到它该看到的那部分文本。从代码库到执行环境都是自己可控的出了任何问题都能顺着链路排查。如果你只是想要一个开箱即用的助手那现成的商业工具确实香。但如果你想在 agent 上做深入研究、定制工具、控制成本、保证数据不出内网原生搭建不是情怀问题是唯一的正确路径。1.3 为什么选 DeepSeek 作为底座模型选型阶段我也对比了其他主流模型最终 DeepSeek 胜出主要靠三个点第一是 API 兼容。它的接口沿用 OpenAI 的消息格式这意味着社区里大量现成的 agent 框架、工具库、prompt 模板都能直接复用不用改一行代码。对做工程的人来说生态兼容性比模型参数本身更能省时间。第二是成本与速度。日常 coding agent 跑一个修两个测试、重构一个函数的小任务大概要消耗十几万 token。这个量级下 DeepSeek 的 token 定价比同级别商业模型低一个量级跑重度 agent 循环不会肉疼。它的响应速度也足够快tool call 一轮往返基本在几秒内agent 循环才能跑得动。第三是开源带来的退路。它是开源权重模型这意味着后续如果我有离线部署的需求——把 agent 完全跑在内网、代码一点不出门——可以直接在本地推理服务上加载模型API 层保持不变。也就是说我现在写的这套代码将来换底座只改一个 base_url 配置。当然它也有短板比如极限复杂度的代码推理、超长上下文的保持力某些场景不如顶级商业化模型。但对日常工程任务自动执行这个定位DeepSeek 的性价比是拉满的。2. 架构设计一个最小可用的 Agent 由哪几块拼起来2.1 核心循环LLM、工具集、执行环境三者怎么协作整个 agent 的骨架我用一句话就能说清楚一个由大模型驱动的事件循环循环里不断做模型输出——如果有工具调用就执行并回传——直到模型给出最终答案。具体展开是这样的把系统提示词、用户任务、历史对话记录组装成 messages发给模型。模型返回两种可能要么是一段纯文本最终答案要么是一个或多个 tool_calls它想调用某个工具并附上参数。如果是 tool_callsagent 在自己的环境里执行对应的函数读文件、写文件、跑 shell 命令等把执行结果构造成 roletool 的消息追加进对话。带着新消息重新进入第 1 步直到模型不再要求调用工具为止。这个循环本身平平无奇但每个环节都有值得注意的细节。比如消息历史的顺序必须严格遵循 API 约定比如工具结果的体积会直接影响上下文消耗再比如模型在同一轮里一次请求多个工具调用时的对齐问题。这些都是后面实操部分的重头戏。我把这个循环称为原生的另一个原因是它没有引入额外的 agent 框架抽象层。不是说现成框架不好而是第一版我希望把所有变量握在自己手里。等这套主循环跑通了再上 LangGraph 或者 MCP 那类更复杂的调度器你才知道底层发生了什么。2.2 DeepSeek 的 tool calling 接口格式详解用 DeepSeek 的 API 时你需要在请求体里传一个 tools 数组每个工具用 JSON Schema 描述它的参数结构。模型在读上下文后如果判断需要用到某个工具就会返回一个结构化的 tool_calls 字段。一个典型请求里的工具定义片段长这样{ type: function, function: { name: read_file, description: 读取指定路径的文件内容用于查看源码或日志, parameters: { type: object, properties: { path: { type: string, description: 要读取的文件路径 }, max_chars: { type: integer, description: 最多读取多少字符防止输出过大 } }, required: [path] } } }模型返回的 tool_call 消息格式大致如下{ role: assistant, tool_calls: [ { id: call_001, type: function, function: { name: read_file, arguments: {\path\: \src/main.py\, \max_chars\: 5000} } } ] }注意 arguments 是一个 JSON 字符串不是对象。新手最容易在这里踩坑直接从字典里取 arguments 当 dict 用结果炸出一堆JSONDecodeError。正确做法永远是先json.loads(arguments)再取字段。拿到 tool_call 之后你要在消息列表里追加一条独立的 tool 角色消息把执行结果回传{ role: tool, tool_call_id: call_001, content: 文件内容截断后的文本... }这个tool_call_id必须和 assistant 消息里的 id 严格一致不然 API 不认。如果你在同一轮里收到 N 个 tool_call那就必须补 N 条 tool 消息缺一条都会报校验错误。这里我多说一句为什么强调消息顺序。DeepSeek 的接口对消息结构有硬性校验如果一个 assistant 消息带了 tool_calls那么紧随其后出现的消息必须是与这些 tool_calls 一一对应的 tool 角色消息或是一个新的 assistant 消息。任何插队的普通 user 消息、或者缺失部分 tool 结果都可能触发类似messages tool calls need immediate results的报错。这个坑我在第 4 章详细说。2.3 工具集怎么定义少而精别堆砌最开始我野心很大给 agent 一口气配了十几个工具语义搜索、REST API 封装、数据库查询、网页抓取……实验结果非常打脸工具一多模型就开始选择困难频繁调用错误的工具甚至在完全不相干的场景里硬套某个工具。调参后发现一个规律工具越少模型的 tool_call 准确率越高。我现在生产用的工具集固定在六个覆盖日常 coding 任务的高频操作工具名功能描述典型使用场景read_file读取指定文件内容查看源码实现、阅读报错日志write_file写入或追加文件内容修改代码、生成补丁、写 LOG 文件list_dir列出目录结构了解项目布局、定位文件位置grep_search在目录中按关键词检索快速定位函数定义、搜索报错关键字run_command在 shell 中执行命令并返回输出跑测试、启动编译、执行 git statusgit_diff查看当前工作区改动让 agent 了解自己改了什么、可回滚到什么状态这六个工具里read_file 和 grep_search 是最常用的大概占 80% 的调用量。run_command 是执行力的核心但也是风险最高的后面安全章节我会专门讲怎么限制它。教训是工具集不是工具箱而是 agent 的手。手太多不但不会干更多活反而会不知道该伸哪只。每次给 agent 加一个工具之前都要问自己这个工具是不是能显著减少模型在现有工具上的推理步骤如果不是就别加。2.4 安全边界沙箱不是可选项一个能自己执行 shell 命令的模型本质上是一把双刃剑。如果放任不管它会做出很多让你心跳骤停的事——比如无比自信地执行rm -rf .cache或者在海量目录里读文件读到上下文爆炸。我的安全策略分三层从弱到强第一层命令黑名单。在 run_command 的实现里我用一个正则黑名单匹配危险命令片段比如rm -rf、sudo、mkfs、:(){:|:};:这类一旦命中直接返回命令被安全策略拦截而不执行。第二层路径白名单。read_file、write_file、list_dir 等文件类工具强制把路径限定在工作区目录内任何试图读取/etc/passwd或~/.ssh/xxx的请求都会被拒绝。实现很简单就是Path.resolve()之后判断是否以工作区前缀开头。第三层交互式确认。这是 debug 模式下最有效的一招run_command 在执行前先把命令打印出来要求人工按回车确认。虽然牺牲了全自动体验但在开发初期能帮你直观看到模型每一步在想什么避免灾难。生产环境的做法是再套一层 Docker 容器代码目录只读挂载agent 在隔离容器里执行命令。这个方案我后面会展开你如果只是本地自用前两层已经能挡住大部分事故。3. 实操从零写一个 DeepSeek 原生的 coding agent3.1 环境准备依赖与基础配置这一段很基础但基础错了后面全是坑。我用的环境是 Python 3.10核心依赖是openaiPython SDK——注意不用装什么特殊库DeepSeek 接口兼容 OpenAI 消息协议直接用 openai SDK 改配置就行。pip install openai # 如果你希望环境变量管理密钥建议用一个 .env 文件 # 并在代码里用 os.getenv(DEEPSEEK_API_KEY) 读取创建客户端的关键就两个参数import os from openai import OpenAI client OpenAI( api_keyos.getenv(DEEPSEEK_API_KEY), base_urlhttps://api.deepseek.com )只要 base_url 和 api_key 配对正确调用方式跟 OpenAI 官方用法一模一样。这也是我强调原生的原因之一同一个代码库将来如果要切到本地部署的 vLLM 或其他兼容服务只需要改一行 base_url 即可。3.2 工具定义与执行器的核心代码我们先把工具集中最核心的三个实现了read_file、write_file、run_command。每个工具函数都接收 JSON 字符串形式的参数返回文本形式的结果这样主循环处理起来非常统一。import json import subprocess from pathlib import Path WORKSPACE Path(/path/to/your/project).resolve() BLACKLIST_PATTERNS [rm -rf, sudo, mkfs, dd if] def normalize_path(raw_path: str) - Path: p Path(raw_path).resolve() if not str(p).startswith(str(WORKSPACE)): return None return p def read_file(tool_args: str) - str: args json.loads(tool_args) p normalize_path(args[path]) if p is None or not p.exists(): return 错误路径越界或文件不存在 max_chars args.get(max_chars, 5000) content p.read_text(encodingutf-8, errorsreplace) return content[:max_chars] def write_file(tool_args: str) - str: args json.loads(tool_args) p normalize_path(args[path]) if p is None: return 错误路径越界 p.parent.mkdir(parentsTrue, exist_okTrue) mode a if args.get(append, False) else w with open(p, mode, encodingutf-8) as f: f.write(args[content]) return f已写入 {p}共 {len(args[content])} 字符 def run_command(tool_args: str) - str: args json.loads(tool_args) cmd args[command] if any(p in cmd for p in BLACKLIST_PATTERNS): return 错误命令命中安全黑名单已拦截 try: result subprocess.run( cmd, shellTrue, capture_outputTrue, textTrue, timeoutargs.get(timeout, 60), cwdWORKSPACE ) output result.stdout result.stderr return output[-30000:] except subprocess.TimeoutExpired: return 错误命令执行超时 TOOL_REGISTRY { read_file: read_file, write_file: write_file, run_command: run_command, }这里有几个细节我要额外强调。路径越界检查不能省哪怕你只是本地自用——因为模型的 tool_call 参数并不总是符合预期一个../../../../etc/hosts的路径可能让 agent 读到敏感文件进而泄露到你的对话记录里。输出截断也是必须的我见过 agent 读了一个超大日志文件结果上下文直接爆掉整轮对话作废。3.3 Agent 主循环实现串起消息流主循环是整个 agent 的心脏。我的实现里用一个messages列表累积全部对话每一轮请求都带上完整的消息历史这种朴素实现虽不优雅但最容易理解也最容易调试。SYSTEM_PROMPT 你是一个运行在本地开发环境中的编码代理。你的任务是真正完成任务而不是给出建议。 规则 1. 动手之前先查看项目结构和相关代码不允许凭空猜测。 2. 优先使用 grep_search 定位相关代码不要一次性读取整个目录。 3. 修改代码时每次只改一个点然后立刻运行测试或构建验证。 4. 报错时先阅读完整报错信息提取关键文件名和行号再定位。 5. 你需要执行命令时直接通过 run_command 执行不要要求用户操作。 def agent_loop(task: str, max_iters: int 15): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task}, ] for _ in range(max_iters): response client.chat.completions.create( modeldeepseek-chat, messagesmessages, toolsTOOLS_SCHEMAS, # 前面定义的那组 JSON Schema tool_choiceauto ) msg response.choices[0].message messages.append(msg.model_dump()) if not hasattr(msg, tool_calls) or not msg.tool_calls: return msg.content for tc in msg.tool_calls: tool_name tc.function.name tool_args tc.function.arguments handler TOOL_REGISTRY.get(tool_name) if not handler: result f错误未知工具 {tool_name} else: result handler(tool_args) messages.append({ role: tool, tool_call_id: tc.id, content: str(result), }) return 达到最大迭代轮数任务未完成是不是有点失望核心代码就这么短。但这份代码里的几个决策值得解释。一是为什么用msg.model_dump()保留完整 assistant 消息。tool_calls 信息必须原样保留在历史里如果只把msg.content存进去下一轮请求里模型会觉得工具调用的上下文丢了可能出现重复调用工具、无法对齐 tool_call_id 等问题。二是为什么把每条 tool 消息的 content 都强制转成字符串。工具函数可能返回任意类型API 要求消息内容必须是字符串。这个小习惯能避免很多头疼的序列化报错。三是模型参数tool_choiceauto。这个值让模型自己判断本轮是否需要调用工具适合通用任务。如果你想强制模型必须调用某个工具比如强制让它先跑一遍 grep可以改成tool_choice{type: function, function: {name: grep_search}}。3.4 System Prompt 的设计心得从建议者到执行者第一次跑通主循环时我发现模型输出总是我应该修改 xxx这样的建议而不是真正动手去改文件。问题出在 System Prompt 的语气上——我把 agent 定义成了助手它自然按助手的行为模式工作。后来我把 prompt 里的关键词全部换成了执行型表达明确你的任务是真正完成任务而不是给出建议——这句话几乎立刻改变了模型的输出模式。增加不要要求用户操作所有命令由你自己通过工具执行——堵住了模型把活推给用户的后路。增加每次只改一个点立刻验证——防止模型一口气改五个文件出错后完全没法定位。这个层面还有一个容易被忽略的点prompt 中的负面表述不要太多。我最早写了一大段不要做 A不要做 B不要做 C模型反而更容易触发这些行为。改成正向引导先查看再修改之后行为明显健康很多。3.5 上下文管理让 agent 具备长期记忆纯靠消息列表堆叠的 agent跑长任务时一定会撞上上下文窗口的上限。DeepSeek 的长上下文能力不错但 token 不是白给的你每多看一个字符都是成本。我的方案是工作区日志文件 阶段性压缩双管齐下。工作区日志文件规定 agent 每完成一个重要步骤就调用 write_file 往项目根目录的AGENT_LOG.md里追加一段记录内容包含本轮目标、做了什么修改、测试结果、下一步计划。这样即便中间对话历史被压缩清空agent 依旧可以通过 read_file 把AGENT_LOG.md读回来恢复记忆。阶段性上下文压缩当 messages 总 token 数超过预设阈值比如 5 万时把早期轮次的消息折叠成一个摘要消息。实现方式是把早期消息丢给一个便宜的模型让它输出 300 字的进度摘要然后用这个摘要替代原始历史。这个方法会丢失一些细节但如果配合 AGENT_LOG.md实际操作中足够可靠。我踩过最大的一个坑是让 agent 读整个项目目录来理解结构。结果它读了几百个 .py 文件上下文爆炸后续所有工具调用都开始在失忆状态下瞎猜。正确做法是第一步先 list_dir 看顶层结构再根据任务关键词 grep_search 定位最后只 read_file 精确读取目标文件。3.6 接入命令行与编辑器变成日常可用的工具主循环写完之后agent 还只是一个光秃秃的函数。要变成日常工具我的做法是用argparse包一层命令行接口python agent.py 修复 tests/test_auth.py 中失败的单测# agent.py import argparse def main(): parser argparse.ArgumentParser() parser.add_argument(task, typestr, help任务描述) args parser.parse_args() result agent_loop(args.task) print(result) if __name__ __main__: main()在这个基础上你在 VSCode 里配置一个 Tasks 条目就能把命令一键跑起来。更进阶一点的做法是把 agent 接入编辑器快捷键选中代码片段后直接传给 agent 处理但这个需要写插件不是这篇文章的重点。我在文章开头提到过这套代码之后如果要切换到其他模型或本地部署只需修改client的 base_url 和 model 参数。如果你把agent_loop里的client.chat.completions.create直接替换成任何 OpenAI 兼容的 endpoint这个 agent 依然能运行。这就是原生集成带来最大红利可移植性。4. 常见问题排查与避坑实录4.1 API 调用异常认证、超时与报错的快速定位跑 agent 最常遇到的第一类问题是 API 调用层面的报错讯息五花八门但根源就那么几个。401 Authentication Error基本都是 API key 没配对。检查两个地方.env里文件是否有尾随空格导致 key 读取错误base_url是否设置正确。DeepSeek 的 API 和它的 Key 是一对绑定关系如果你同时开了其他模型的 key很容易复制错。Request timed out则分两种网络层超时和工具执行超时。网络层超时通常表现在 create 调用挂起很久然后抛异常这种可以加一个 timeout 参数来控制工具执行超时更常见于 run_command 里跑了一些耗时命令比如启动一个开发服务器。我的经验是给 subprocess 加 timeout并明确告诉模型长命令最多执行 60 秒超时需要自己想办法分批执行。还有一种隐藏很深的报错This model does not support function calling。如果你用的是一个不支持工具调用的模型版本比如某些早期版本或微调过的私有版本API 会直接拒绝 tools 参数。解决办法就是把 model 参数换成 deepseek-chat 或明确支持 tool call 的版本。4.2 messages tool calls need immediate results报错怎么彻底解决这个报错我遇到过四五次每次都是在调试会话时被折腾半天。核心原因是DeepSeek 兼容接口对消息结构有严格校验——一旦 assistant 消息里携带了 tool_calls后续消息中必须立刻出现这些 tool_call 的执行结果roletool 的消息不能再插任何无关消息。最常见的触发场景有三个一是插入了一条 roleuser 的追问。模型返回 tool_call 后调试代码里先 append 了一条请继续的 user 消息这就破坏了消息顺序。正确的做法是收到 tool_call 后立刻执行工具、立刻 append tool 消息然后才可以把新的 user 消息放在更后面。二是多 tool_call 只回传了一部分。当模型一次请求调用多个工具时你必须给每个 tool_call_id 都补一条 tool 消息。很多人只回传了实际执行成功的那个另一个被跳过API 立刻报 need immediate results。三是在工具执行结果返回之前重新组装了历史消息。有时候我会做上下文压缩如果压缩逻辑把携带 tool_calls 的 assistant 消息砍掉了或者把 tool 消息重排了位置就会触发校验失败。解法非常机械但很有效# 收到消息后先检查 tool_calls if msg.tool_calls: # 直接进入执行和回传循环不插任何消息 for tc in msg.tool_calls: result execute_tool(tc.function.name, tc.function.arguments) messages.append({ role: tool, tool_call_id: tc.id, content: str(result) }) continue # 回到循环头部重新请求模型记住一个口诀tool_call 之后只有两个合法选择补 tool 结果或者再给一个新的 assistant带/不带 tool_call 的消息除此之外什么都不能插。4.3 模型幻觉命令的防御与灾难恢复有一次我跑一个清理临时文件的场景模型在没有任何确认的情况下给出了rm -rf .cache的命令。当时我的黑名单里其实已经写了rm -rf但因为命令被写成了rm -rf ./.cache正则没能匹配上差点出事。这个教训让我把防御策略改成了三层第一层命令正则黑名单升级为危险 token 检测。对rm -rf、shutdown、chmod -R 777、 /dev/sda这类片段做模糊匹配不再依赖精确字符串。第二层debug 模式强制确认。在本地开发阶段所有 run_command 执行前先input()等人工按回车。虽然麻烦但它能让你直观看到模型的判断轨迹及时止损。第三层文件系统快照。每次 agent 启动时我用git status --porcelain记录工作区状态数据有异常可以git checkout .一键回滚。agent 本身也配置了 git_diff 工具它能在每次改动后查看自己的变更及时撤销错误的编辑。要特别强调一点永远不要在生产项目上第一次跑新写的 agent。先在临时目录 copy 一个玩具项目让 agent 随便折腾验证它的行为模式稳定了再放到真实代码库去。4.4 成本与上下文优化让 agent 跑得既快又省我统计过一次修复两个单测失败并重构一个辅助函数的任务总消耗大概 16 万 token。其中大部分消耗来自反复读取整个文件的全文以及模型在长上下文下的重复检索。我的优化策略是把精确读取当成核心原则让 grep_search 前置。比如先 grep 到函数定义在第 57 行再读文件时直接告诉模型只读 40-80 行减少无效 token。另一个优化是压缩工具结果的体积。read_file 默认只返回前 5000 字符run_command 默认只返回输出末尾 3 万字符。特别是在跑 test 命令时模型最需要的是失败断言和堆栈尾部而不是全部测试输出。还有一个很多人忽略的细节把历史里的中间 tool_call 数据做瘦身。那些长长的工具调用参数和结果在后续轮次里其实没用了。你可以每三轮做一次历史折叠把助手侧的过程性信息压缩成一句话只保留最近几轮的完整消息。成本控制本质是上下文管理。上下文越短模型推理越快token 越省准确率还越高。这是一个四赢的局面。写在最后最后聊一点我的实操体会。从零搭完这套 DeepSeek 原生 AI coding agent 之后最大的感受是与其追着各种智能框架跑不如先把原生的调用链路打磨稳。DeepSeek 的 function calling 接口在工程上非常稳定只要消息顺序不出错、上下文管理得当它完全能胜任修 bug、补测试、做小规模重构这类日常任务。最后分享一个我反复验证过的小技巧在 system prompt 里让 agent每次只改一处并立刻跑验证这比让它一口气重写整个模块可靠得多。顺便记得给 run_command 设置明确的超时时间任务如果跑超过三分钟就让它停下来把中间状态写进 AGENT_LOG.md再开新的一轮继续——这个习惯替我避免了无数次改了五个文件结果全错了的惨剧。下一步我打算把本地小模型作为初筛DeepSeek 作为最终执行者做一个两级 agent 架构用更低的成本跑同样的任务。如果你也在折腾 AI coding agent希望这篇文章能帮你少踩几个我踩过的坑。
