mini-cc v0.1:一个只会看的 Agent

4,948 字 · 预计阅读 14 分钟

读完源码,现在把 30 行的循环变成一个真能跑的 mini-cc。

这一版最终只给它三个只读工具:读文件、找文件、搜内容。

只读工具不会改坏任何东西,不用考虑权限,正好让我们先专心把循环写对。

整个 v0.1 分成五个阶段,每个阶段结束都能直接看到结果。咱们小步快跑,一级一级来。

跟着本节走完,你会有一个能跑起来的 mini-cc。

阶段 这一级只做一件事 跑起来看到什么
1. 一次请求 把话发出去,把回答打出来 模型回你一句话(还没有工具)
2. 一个工具 模型说要读文件,程序真去读 ⏺ Read(...),然后……它不说话了
3. 循环 读完接着说,说到没有工具为止 它自己读完再回答,到这一级才算 Agent
4. 多两个工具 同一套格式再写两遍 问目录结构,它先 Glob 再 Read
5. 打断和界面 只追加不删除;一行一行读输入 能连续对话,按 Ctrl+C 不崩

一、一次请求:先确认你能和模型说上话

本阶段只做一件事:把 messages 发出去,把回来的 text 打出来。没有循环,也没有工具。

能跑通,说明密钥、网络、请求体三件事都对了。后面无论加什么,都是在这条请求上做加法。

新建 mini_cc.py:

mini_cc.py新文件 · 整段粘贴+65第 1/12 步
python
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):

bash
export DEEPSEEK_API_KEY=你的密钥
python3 mini_cc.py

看到有内容回复就成功了,不一定是下面的文案(因为AI有随机性)

能用AI CC 是一个在终端里帮你完成软件工程任务的 Agent……

这一步要是报 401,先别往下走:密钥没设对,或者没 export。


二、给它一个工具:tool_use 和 tool_result

模型自己不会执行任何东西。它只会回一种特殊的块:

json
{
  "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)

本文是会员内容

全文 4,948 字,已试读约 28%。开通专业版可阅读全文,并解锁全部官方课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟价 99 元/年(售完恢复365/年)

课程中涉及的第三方项目,其著作权与商标归各自权利人所有;引用处的署名与许可见《开源与来源说明》。