读完源码,现在把 30 行的循环变成一个真能跑的 mini-cc。
这一版最终只给它三个只读工具:读文件、找文件、搜内容。
只读工具不会改坏任何东西,不用考虑权限,正好让我们先专心把循环写对。
整个 v0.1 分成五个阶段,每个阶段结束都能直接看到结果。咱们小步快跑,一级一级来。
跟着本节走完,你会有一个能跑起来的 mini-cc。
| 阶段 | 这一级只做一件事 | 跑起来看到什么 |
|---|---|---|
| 1. 一次请求 | 把话发出去,把回答打出来 | 模型回你一句话(还没有工具) |
| 2. 一个工具 | 模型说要读文件,程序真去读 | ⏺ Read(...),然后……它不说话了 |
| 3. 循环 | 读完接着说,说到没有工具为止 | 它自己读完再回答,到这一级才算 Agent |
| 4. 多两个工具 | 同一套格式再写两遍 | 问目录结构,它先 Glob 再 Read |
| 5. 打断和界面 | 只追加不删除;一行一行读输入 | 能连续对话,按 Ctrl+C 不崩 |
一、一次请求:先确认你能和模型说上话
本阶段只做一件事:把 messages 发出去,把回来的 text 打出来。没有循环,也没有工具。
能跑通,说明密钥、网络、请求体三件事都对了。后面无论加什么,都是在这条请求上做加法。
新建 mini_cc.py:
import json, os, urllib.error, urllib.request
from pathlib import Path
# 协议就是 Anthropic Messages API,这里固定走 DeepSeek 的兼容入口
BASE_URL = "https://api.deepseek.com/anthropic"
# 密钥从环境变量读;没设就是空串,第一次调用就会报错
AUTH_TOKEN = os.environ.get("DEEPSEEK_API_KEY", "")
MODEL = "deepseek-flash"
# DeepSeek 单次输出上限 8K,留个环境变量方便临时调小
MAX_TOKENS = int(os.environ.get("MINI_CC_MAX_TOKENS", "8192"))
# 工具的路径都相对启动目录算
CWD = Path.cwd()
SYSTEM = "你是能用AI CC(mini-cc),一个在终端里帮用户完成软件工程任务的 Agent。修改文件之前先读,危险操作先问用户。"
class APIError(Exception):
"""请求层的错误:main() 用它区分 API 报错和程序 bug。"""
class _Obj(dict):
"""让 JSON 字典也能用 resp.content 这样点出来(和 SDK 返回值一致)。"""
def __getattr__(self, key):
try:
return self[key]
except KeyError:
raise AttributeError(key) from None
def to_obj(value):
"""递归包装:dict → _Obj,list 逐项处理。"""
if isinstance(value, dict):
return _Obj({k: to_obj(v) for k, v in value.items()})
if isinstance(value, list):
return [to_obj(v) for v in value]
return value
def text_message(text):
return {"role": "user", "content": [{"type": "text", "text": text}]}
def call_model(system, messages):
if not AUTH_TOKEN:
raise APIError("没找到 API 密钥:设置 DEEPSEEK_API_KEY")
headers = {"content-type": "application/json", "anthropic-version": "2023-06-01",
"authorization": f"Bearer {AUTH_TOKEN}"}
body = json.dumps({"model": MODEL, "max_tokens": MAX_TOKENS,
"system": system, "messages": messages}).encode("utf-8")
request = urllib.request.Request(f"{BASE_URL}/v1/messages", data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=600) as response:
return to_obj(json.loads(response.read().decode("utf-8")))
except urllib.error.HTTPError as e:
# 带上响应体前 500 字,方便定位
raise APIError(f"{e.code} {e.reason}:{e.read().decode('utf-8', 'replace')[:500]}") from None
except urllib.error.URLError as e:
raise APIError(f"连不上 {BASE_URL}:{e.reason}") from None
# —— 这一级的临时入口:下一级换成工具调用 ——
resp = call_model(SYSTEM, [text_message("用一句话介绍你自己")])
# 只取文字块:content 里还可能混着 thinking 块
print("".join(b.text for b in resp.content if b.type == "text"))中间那段 _Obj / to_obj 是样板(约 25 行):让 json 解析出来的字典能用 resp.content 这样点出来(原来 SDK 返回的就是这种对象)。扫一眼就行,可以直接跳到 text_message。
用下面这条命令跑(MINI_CC_MAX_TOKENS 不用设,默认 8192):
export DEEPSEEK_API_KEY=你的密钥
python3 mini_cc.py看到有内容回复就成功了,不一定是下面的文案(因为AI有随机性)
能用AI CC 是一个在终端里帮你完成软件工程任务的 Agent……这一步要是报 401,先别往下走:密钥没设对,或者没 export。
二、给它一个工具:tool_use 和 tool_result
模型自己不会执行任何东西。它只会回一种特殊的块:
{
"type": "tool_use",
"id": "toolu_01…",
"name": "Read",
"input": {
"file_path": "hello.txt"
}
}意思是"我要调 Read,参数是这个"。真去读文件的是你的程序。读完要把结果包成 tool_result,带上同一个 id,作为一条 user 消息塞回 messages。下一次请求里,模型才看得见文件内容。
所以一个工具有两半:写给模型看的(名字、说明、参数格式),和写给程序用的(run)。
这一节动三处,每改一处都能单独跑一次,不用等全改完才知道错在哪:
| 步 | 动哪里 | 跑起来看到什么 |
|---|---|---|
| 1. 请求层 | callModel 多收一个 tools,调用点先传个空的 |
和上一节一样的自我介绍,没坏,只是请求里多了个空的 tools |
| 2. 工具层 | ToolError … executeTool 整段贴进去 |
还是一句自我介绍,工具躺在文件里,没人调用它 |
| 3. 临时入口 | 换掉文件最末那几行 | ⏺ Read(...) + hello world + "停在这里" |
改完整个文件就是这个顺序(Java 的入口在 main() 里,位置一样):
mini-cc.mjs
├─ 文件头:import + 常量 ← 不动
├─ 请求层:textMessage / callModel ← 第一处改这里
├─ 工具层:ToolError … executeTool ← 第二处整段贴这里
└─ 临时入口:最后 8 行 ← 第三处改这里从上往下只有一条规矩:定义在上,使用在下。临时入口是“使用”,它要读 BASE_TOOLS,所以永远压在最末;新贴的工具代码一律在它上面。
第一处改动:请求层多带一个 tools 字段,把每个工具的声明发出去:name、description、input_schema 三个字段。真正干活的 run 函数留在本地,模型看不到,也用不上。
call_model() 只改这几处,右上角复制的是改完的完整代码(可完整替换callModel)