这份文档是全项目的”宪法”。每次开始新一节之前先扫一眼,确保没有跑偏。
文档里的协议细节全部是实测的(用
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 |
| JSON | Jackson | 就这一个第三方依赖 |
| HTTP | java.net.http.HttpClient(JDK 自带) | 不引额外库 |
| 测试 | JUnit 5 | 关键逻辑要能单测 |
| 框架 | 无 | 不用 Spring——它会替你做完你想学的事 |
| 模型 | deepseek-flash | 便宜,适合反复试 |
| 界面 | 命令行 REPL | 最小可用 |
为什么不用官方 SDK,要手写 HTTP?
因为你想学的核心细节,恰好都被 SDK 封装掉了:
- 模型是怎么一个字一个字吐出来的(流式解析)
- 工具参数是怎么一段一段拼起来的(增量 JSON 累积)
- 一轮什么时候算结束(
finish_reason在哪个 chunk 上) - token 用量的数字长什么样
用 SDK 你会得到一行 client.chat(...),什么也学不到。手写大约 150 行,值得。
3. 模型事实(实测 + 官方文档核对)
| 项 | 值 |
|---|---|
| 模型 ID | deepseek-flash(底层是 DeepSeek-V4.1-Flash) |
| 备用模型 ID | deepseek-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:"{"}}]
...
三个必须记住的点:
- 第一片才有
id/type/function.name,且arguments是空串。之后的片只有index和function.arguments。 - 所以要维护一张
index → (id, name)的表,才能知道后面那些参数碎片属于哪个工具。 - 必须累积成完整字符串再 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}
两条硬规则:
- 只有一方有值。推理时
content是null,正式回答时reasoning_content是null。 - ⚠️ 如果请求带了
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. 里程碑
每一节都是能跑、能玩的,不存在”写了三百行还看不到东西”的情况。
| 内容 | 主角 | 学到什么 | |
|---|---|---|---|
| M1 | REPL + 流式对话,无工具 | StreamEvent / DeepSeekProvider | SSE 协议、事件归一化、流式解析 |
| M2 | 只读工具 read_file / glob / grep + 完整循环 | Agent / Tool / AssistantTurn | tool_use 循环、schema 定义、增量参数累积 |
| M3 | 写工具 write_file / edit_file / run_bash + 审批 | PermissionManager / DangerDetector | 权限模型、路径沙箱、危险命令拦截 |
| M4 | token 预算 + 自动压缩 + 上下文缓存 | Compactor / TokenEstimator | 上下文管理(最难) |
| M5 | 第二个 Provider + MCP | AnthropicProvider / McpToolAdapter | 抽象到底成不成立 |
M1 故意不带工具,因为流式解析和工具循环是两个独立的难点,一起上会互相干扰。
7. 怎么用这些文档
- 先通读一节,别急着敲。搞清楚”为什么要有这个类”比”这个类怎么写的”重要。
- 照着敲,不要复制粘贴。敲的过程会逼你注意每个标识符,复制粘贴会跳过这一步。
- 注释里带 ⚠️ 的是坑,带 💡 的是理解的关键。
- 跑起来,改坏了再改回来。学习发生在”我改一下看看会怎样”的时候。
- 每节末尾有自检清单。过了再进下一节。
关于类比
复杂概念我会给类比。注意:类比只负责让你”大概知道那是什么”,不负责精确。理解之后要把类比扔掉,回到准确的定义上。
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(比如贴在聊天窗口里的)请去控制台吊销重发。