Agent 实战(零):从零手写一个编码 Agent,先想清楚这六条铁律

这份文档是全项目的”宪法”。每次开始新一节之前先扫一眼,确保没有跑偏。

文档里的协议细节全部是实测的(用 curl 打真实 SSE 抓下来的),不是从记忆里写的。


1. 这个项目要做什么

从零手写一个编码 agent(就是 Claude Code / pi 那种东西)的最小可用版本。

不是为了做产品,是为了把 agent 的每一块零件都亲手装一遍。

四个学习重点

重点一句话说清它在解决什么
核心 agent loop模型说”我要调工具”,你执行完把结果喂回去,让它接着说。这个循环是整个 agent 的心跳。
工具系统设计把”读文件""跑命令”包装成模型能理解、能调用的形式。涉及 schema 定义、并发、结果截断。
上下文管理对话越来越长,token 有限。什么时候压缩、压缩什么、怎么不丢关键信息。这是最难的。
权限与安全模型让删库你要不要照做?路径能不能跑出项目目录?让 agent 从”能跑”变成”敢用”。

明确不做的事

  • ❌ 不做漂亮的 TUI(全屏重绘、diff 高亮那些)——那是壳,不是 agent
  • ❌ 不接 MCP(第一版)
  • ❌ 不做多用户 / 服务端部署
  • ❌ 不用 Spring

2. 技术选型

项选择理由
语言Java 21 LTS显式类型让每一步都看得见;sealed + switch 模式匹配能在编译期查漏;虚拟线程让并行工具调用写得像同步代码
构建Maven机器上已有 3.9.15
JSONJackson就这一个第三方依赖
HTTPjava.net.http.HttpClient(JDK 自带)不引额外库
测试JUnit 5关键逻辑要能单测
框架无不用 Spring——它会替你做完你想学的事
模型deepseek-flash便宜,适合反复试
界面命令行 REPL最小可用

为什么不用官方 SDK,要手写 HTTP?

因为你想学的核心细节,恰好都被 SDK 封装掉了:

  • 模型是怎么一个字一个字吐出来的(流式解析)
  • 工具参数是怎么一段一段拼起来的(增量 JSON 累积)
  • 一轮什么时候算结束(finish_reason 在哪个 chunk 上)
  • token 用量的数字长什么样

用 SDK 你会得到一行 client.chat(...),什么也学不到。手写大约 150 行,值得。


3. 模型事实(实测 + 官方文档核对)

项值
模型 IDdeepseek-flash(底层是 DeepSeek-V4.1-Flash)
备用模型 IDdeepseek-v4-pro(约 4 倍价格,本项目用不上)
Base URL(OpenAI 格式)https://api.deepseek.com
调用端点POST https://api.deepseek.com/chat/completions
认证Authorization: Bearer <API_KEY>
上下文长度1M tokens
最大输出384K tokens
工具调用✅ 支持
思考模式✅ 支持,默认开启,默认强度 high

价格(每 1M tokens)

缓存命中缓存未命中输出
非高峰$0.003$0.15$0.60
高峰$0.006$0.30$1.20
  • 高峰时段:UTC 周一至周五 01:00–04:00 和 06:00–10:00。其余时间(含周末)全是非高峰,半价。
  • 注意”缓存命中”比”未命中”便宜 50 倍——这是 DeepSeek 的上下文缓存(KV cache)。M4 会专门利用它。

思考模式怎么开关

{"thinking": {"type": "disabled"}}     // 关
{"thinking": {"type": "enabled"}, "reasoning_effort": "low"}   // 开,强度 low/high/max

⚠️ 思考模式有几个副作用:

  • temperature / presence_penalty / frequency_penalty 全部失效(传了不报错,但被忽略)
  • top_p 只在思考模式下生效,且被夹在 0.95–1.0

4. 六条铁律

这六条贯穿 M1–M5,违反了会出难查的 bug。每条都标注了是不是实测确认的。

铁律 1 · 工具结果是一条独立的 role:"tool" 消息 ✅实测

模型一次可能请求调用多个工具。你要为每一个工具单独发一条消息:

{"role": "assistant", "content": null, "tool_calls": [ {...}, {...} ]}
{"role": "tool", "tool_call_id": "call_00_EQSI...", "content": "杭州:多云 24℃"}
{"role": "tool", "tool_call_id": "call_01_duTt...", "content": "北京:晴 26℃"}

对比:Anthropic(Claude)要求把所有工具结果塞进同一条 user 消息。方向相反。 这正是 Provider 抽象要抹平的差异——上层只看到”有一批工具结果”,怎么编码由 Provider 决定。

铁律 2 · 继续循环的信号是 finish_reason == "tool_calls" ✅实测

不是”消息里有没有 tool_calls 字段”。实测中模型先说了句话再调工具(content: "I'll check the weather in both cities for you." 和 tool_calls 在同一轮里)。

对比:Anthropic 叫 stop_reason == "tool_use"。名字不同,语义对应。

铁律 3 · 工具参数是分片 JSON;index 是唯一标识,id/name 只在第一片出现 ✅实测

真实的分片长这样(已经省略了每条都要带的一大堆字段):

delta.tool_calls:[{index:0, id:"call_00_EQSI...", type:"function", function:{name:"get_weather", arguments:""}}]
delta.tool_calls:[{index:0, function:{arguments:"{"}}]
delta.tool_calls:[{index:0, function:{arguments:"\""}}]
delta.tool_calls:[{index:0, function:{arguments:"location"}}]
delta.tool_calls:[{index:0, function:{arguments:"\""}}]
delta.tool_calls:[{index:0, function:{arguments:": "}}]
delta.tool_calls:[{index:0, function:{arguments:"H"}}]
delta.tool_calls:[{index:0, function:{arguments:"ang"}}]
delta.tool_calls:[{index:0, function:{arguments:"zhou"}}]
delta.tool_calls:[{index:0, function:{arguments:"\""}}]
delta.tool_calls:[{index:0, function:{arguments:"}"}}]
delta.tool_calls:[{index:1, id:"call_01_duTt...", type:"function", function:{name:"get_weather", arguments:""}}]
delta.tool_calls:[{index:1, function:{arguments:"{"}}]
...

三个必须记住的点:

  1. 第一片才有 id / type / function.name,且 arguments 是空串。之后的片只有 index 和 function.arguments。
  2. 所以要维护一张 index → (id, name) 的表,才能知道后面那些参数碎片属于哪个工具。
  3. 必须累积成完整字符串再 JSON 解析。绝对不能写 if (args.contains("\"path\"")) 这种字符串匹配——分片边界会把你玩死(看上面 H / ang / zhou 被切成了三片)。

类比:像分拣线上的包裹。第一个到达的片段带着收件人标签(id/name),后面的片段只有一个流水号(index)和一点内容。你得靠流水号把内容归到正确的包裹上,最后才能拆箱(JSON 解析)。

铁律 4 · 工具 schema 是三层嵌套

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "获取某个城市的当前天气",
    "parameters": {
      "type": "object",
      "properties": { "location": { "type": "string", "description": "城市名" } },
      "required": ["location"]
    }
  }
}

parameters 在 function 里面,不是顶层。写错位置模型收不到工具,而且不会报错——它只是变得”不会用工具”,你会对着空气调试半天。

铁律 5 · usage 和 finish_reason 在同一个 chunk 上 ✅实测

一个完整的流,最后三行长这样:

data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":10}}

data: [DONE]

⚠️ 和 OpenAI 不一样:不存在 "choices": [] 的独立 usage 帧,也不需要 stream_options.include_usage。DeepSeek 默认就在最后一帧上带 usage。

  • [DONE] 是哨兵,不是 JSON,别拿去 parse。
  • 结束标志是 finish_reason 变成非 null,不是 [DONE]。
  • 收到 finish_reason 时就把 Done 事件发出去,顺手把同一帧上的 usage 带上——不要等 [DONE]。

铁律 6 · 思考模式下的 reasoning_content:与 content 互斥,且带 tools 时必须回传 ✅实测 + 文档

开启思考模式后,delta 里多一个 reasoning_content 字段,且 content 会显式出现为 null:

delta: {"role":"assistant","content":null,"reasoning_content":""}
delta: {"content":null,"reasoning_content":"We"}
delta: {"content":null,"reasoning_content":" need"}
...
delta: {"content":"391","reasoning_content":null}

两条硬规则:

  1. 只有一方有值。推理时 content 是 null,正式回答时 reasoning_content 是 null。
  2. ⚠️ 如果请求带了 tools 参数,所有历史轮次的 reasoning_content 必须完整回传,否则 API 直接返回 400。不带 tools 的请求则不需要回传(传了也会被忽略)。

第 2 条是 M2 的一个大坑:你会在 M2 突然从”能跑”变成”400”,而且错误信息不会告诉你是这个原因。 所以 M1 就先把 ReasoningDelta 事件和 Message 的扩展点准备好。


5. 架构

5.1 数据流

                        ┌─────────────────────────────────────┐
   "帮我看看 Main.java"  │  Main (REPL)                        │
    ───────────────────▶│  · 读输入                           │
                        │  · 维护 conversation 历史           │
                        └──────────────┬──────────────────────┘
                                       │  List<Message>
                                       ▼
                        ┌─────────────────────────────────────┐
                        │  Agent  ← M2 才出现                  │
                        │  while (还没结束) {                  │
                        │     调模型 → 有工具? → 执行 → 回灌   │
                        │  }                                   │
                        └──────────────┬──────────────────────┘
                                       │  ChatRequest
                                       ▼
                        ┌─────────────────────────────────────┐
                        │  Provider (接口)  ← 万能插头         │
                        │  void stream(req, onEvent)          │
                        └──────────────┬──────────────────────┘
                                       │
                                       ▼
                        ┌─────────────────────────────────────┐
                        │  DeepSeekProvider                   │
                        │  · 拼 JSON 请求                     │
                        │  · 读 SSE 每一行                    │
                        │  · 翻译成 StreamEvent ───┐          │
                        └─────────────────────────┼──────────┘
                                                  │
                    ┌─────────────────────────────┘
                    ▼
        StreamEvent (TextDelta / ReasoningDelta / ToolUseStart / ...)
                    │
                    └──▶ 回调给 Main / Agent 去渲染或处理

关键分界线:Provider 左边是”全世界统一的语言”(StreamEvent),右边是”某个厂商的方言”(DeepSeek 的 SSE)。换厂商只需要换右边。

类比:像电源转换插头。中国的插座、美国的插座、英国的插座形状都不一样(各家 API 的原始事件格式不同),但你的笔记本电脑只认一种插头(StreamEvent)。Provider 就是那个转换头。M1 只有一个实现,你感受不深;M5 加第二个时你会有”哦——原来是为了这个”的感觉。

5.2 包结构

D:\codeSpace\agent\
├── pom.xml
├── docs\
│   ├── 00-总览与架构.md               ← 本文件
│   ├── 01-M1-打通流式对话.md
│   ├── 02-M2-工具系统与核心循环.md
│   ├── 03-M3-写工具与权限系统.md
│   ├── 04-M4-上下文管理.md
│   └── 05-M5-验证抽象与MCP.md
└── src\main\java\dev\learn\agent\
    ├── Main.java                      REPL 入口 + 参数解析
    ├── provider\
    │   ├── Provider.java              ★ 接口
    │   ├── StreamEvent.java           ★ 事件词汇表(sealed)
    │   ├── Message.java               消息
    │   ├── ChatRequest.java           一次请求
    │   ├── ToolSpec.java              工具的厂商无关说明
    │   ├── ToolCall.java              组装完成的工具调用
    │   ├── StopReason.java            为什么停
    │   ├── Usage.java                 token 用量
    │   ├── deepseek\
    │   │   └── DeepSeekProvider.java  OpenAI / Chat Completions 方言翻译器
    │   └── anthropic\
    │       └── AnthropicProvider.java Anthropic / Messages 方言翻译器
    ├── agent\
    │   ├── Agent.java                 ★ 核心循环
    │   ├── AssistantTurn.java         ★★ 流式事件累积器
    │   └── AgentListener.java         回调接口
    ├── tool\
    │   ├── Tool.java                  ★ 工具接口
    │   ├── ToolContext.java           工作目录 + 路径沙箱 + 权限管理器
    │   ├── ToolResult.java            执行结果
    │   ├── ToolRegistry.java          ★ 注册表 + 并发执行 + 权限检查
    │   ├── Schema.java                JSON Schema 小 DSL
    │   └── impl\
    │       ├── ReadFileTool.java      read_file
    │       ├── GlobTool.java          glob
    │       ├── GrepTool.java          grep
    │       ├── WriteFileTool.java     write_file
    │       ├── EditFileTool.java      edit_file(带唯一性检查)
    │       └── BashTool.java          run_bash
    ├── permission\
    │   ├── Risk.java                  风险等级
    │   ├── Approval.java              审批结果
    │   ├── Approver.java              审批人接口
    │   ├── DangerDetector.java        ★ 危险命令识别
    │   └── PermissionManager.java     ★ 权限决策中心
    ├── context\
    │   ├── TokenEstimator.java        token 估算
    │   ├── ContextBudget.java         预算与阈值
    │   └── Compactor.java             ★ 历史压缩
    └── mcp\
        ├── McpClient.java             ★ stdio JSON-RPC 客户端
        ├── McpToolAdapter.java        ★ MCP 工具 → Tool 适配器
        └── demo\
            └── DemoMcpServer.java     自制测试用 MCP 服务器

★ = 关键文件。


6. 里程碑

每一节都是能跑、能玩的,不存在”写了三百行还看不到东西”的情况。

内容主角学到什么
M1REPL + 流式对话,无工具StreamEvent / DeepSeekProviderSSE 协议、事件归一化、流式解析
M2只读工具 read_file / glob / grep + 完整循环Agent / Tool / AssistantTurntool_use 循环、schema 定义、增量参数累积
M3写工具 write_file / edit_file / run_bash + 审批PermissionManager / DangerDetector权限模型、路径沙箱、危险命令拦截
M4token 预算 + 自动压缩 + 上下文缓存Compactor / TokenEstimator上下文管理(最难)
M5第二个 Provider + MCPAnthropicProvider / McpToolAdapter抽象到底成不成立

M1 故意不带工具,因为流式解析和工具循环是两个独立的难点,一起上会互相干扰。


7. 怎么用这些文档

  1. 先通读一节,别急着敲。搞清楚”为什么要有这个类”比”这个类怎么写的”重要。
  2. 照着敲,不要复制粘贴。敲的过程会逼你注意每个标识符,复制粘贴会跳过这一步。
  3. 注释里带 ⚠️ 的是坑,带 💡 的是理解的关键。
  4. 跑起来,改坏了再改回来。学习发生在”我改一下看看会怎样”的时候。
  5. 每节末尾有自检清单。过了再进下一节。

关于类比

复杂概念我会给类比。注意:类比只负责让你”大概知道那是什么”,不负责精确。理解之后要把类比扔掉,回到准确的定义上。


8. 环境准备

# 1. 确认 JDK 21
java -version          # 应输出 21.x

# 2. 设置 API key(⚠️ 不要写进代码!)
export DEEPSEEK_API_KEY="你的key"

# 3. 确认模型 ID
curl -s https://api.deepseek.com/models \
  -H "Authorization: Bearer $DEEPSEEK_API_KEY"

⚠️ API key 只走环境变量。任何情况下都不要写进 .java 文件、不要提交到 git。 已经泄露过的 key(比如贴在聊天窗口里的)请去控制台吊销重发。