最近半年,问得最多的一类问题不是“哪个大模型更强”,而是“有没有一个工具,能把 DeepSeek、GLM、豆包、千问这些国产模型都用上,还能顺手顶替掉 Codex 那类云端编码助手,最好每天还有免费额度兜底”。这个问题背后其实藏着一个很现实的工程矛盾:模型越来越便宜、越来越好用,但开发者接入它们的成本并没有同步下降。
Codex 这类云端编码 Agent 的价值在于“把任务丢进去,它自己读文件、改代码、跑测试”,但它的问题也很明显——模型选择被锁死、额度按订阅算、代码要上传到别人的环境、企业内部项目基本不敢用。而国产模型的优势恰恰相反:单价低、中文理解好、有免费或低价档位、部分平台每天还有赠送额度,可它们各自为战,SDK 不一样、鉴权不一样、流式格式不一样、工具调用(function calling)的参数细节也不一样。
所以本文要讲的不是“某个软件有多神”,而是一件更本质的事:如何用一层几十行的适配代码,把 DeepSeek、GLM、豆包、千问统一成同一个接口,并在这层之上搭一个属于自己的编码 Agent。这层适配做完之后,所谓“每日免费额度”才真正有意义——因为它不再绑定在某个具体产品上,而是你可以随时替换的一个 Provider。
读完你能拿到三样东西:一套可直接运行的、OpenAI 兼容协议的多模型接入代码;一个带工具调用循环的最小 Agent,能读写文件、跑命令;一份关于免费额度、限流、降级、密钥管理和沙箱安全的实践清单。至于要不要花钱、要不要上付费档,等你把这套东西跑通之后,答案会自己浮出来。
1. 免费额度真正解决的是哪一类成本
先把“免费额度”这件事拆开看,避免被营销话术带偏。很多工具宣传“每日赠送 5000 积分”,听起来很美好,但如果你仔细算一笔账,会发现积分制本质上是把 token 消耗折算成了一个不透明的虚拟单位。你并不知道一次 Agent 任务跑了多少轮、读了多少文件、发了多少次上下文,最后扣了多少分,这个过程对开发者来说是个黑盒。
真正决定成本高低的,从来不是单价,而是调用次数乘以平均上下文长度。一个编码 Agent 完成“给这个模块补单元测试”这样的任务,通常要经历 5 到 15 轮工具调用,每轮都把系统提示、工具定义、文件内容、历史消息重新发一遍。即使模型单价只有每百万 token 一两块钱,上下文一膨胀,单次任务的消耗依然可观。
这意味着两件事。第一,免费额度适合做“日常小任务”和“验证性任务”,不适合做长时间、大仓库、多轮探索的重活。第二,也是更重要的一点:你必须能随时把请求切到另一个模型上。如果所有能力都绑死在某个产品的额度体系里,一旦额度用完、规则调整、接口变更,你的工作流就直接断掉。
所以下面这套方案的目标很明确:把“用哪个模型”变成配置文件里的一行改动,把“额度够不够”变成日志里一个可观测的数字。模型是消耗品,接入层才是资产。
| 关注点 | 绑定单一工具 | 自建接入层 |
|---|---|---|
| 模型选择 | 平台决定 | 配置文件决定,随时切换 |
| 免费额度 | 平台规则,不可控 | 多平台并行,额度可轮换 |
| 成本可见性 | 积分黑盒 | token 级记账,可估算 |
| 代码安全 | 上传到第三方 | 本地执行,可控沙箱 |
| 供应商故障 | 直接中断 | 自动降级到备用模型 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念对齐:兼容协议、Agent 循环与积分折算
在动手写代码之前,有三个概念必须先对齐,否则后面很容易写着写着就跑偏。
2.1 OpenAI 兼容协议是这套方案的基石
所谓“OpenAI 兼容”,指的是供应商提供的 HTTP 接口在路径、请求体、响应体结构上模仿 OpenAI 的 /v1/chat/completions。只要接口形状一致,你就可以用同一个 SDK 去调不同厂商的模型,唯一需要改的是 base_url、api_key 和 model 三个参数。
目前主流的国产模型平台基本都提供了这种兼容模式,有的作为主推接口,有的作为“兼容层”额外提供。这对我们极其有利:不需要为每家厂商写一套 SDK 封装,只需要维护一张模型能力表,记录每个模型的地址、密钥环境变量、是否支持工具调用、上下文窗口大小。
这里真正容易踩坑的地方是:兼容并不等于完全一致。工具调用的返回结构、流式响应的结束标志、max_tokens 的含义、部分模型对 system 消息的位置要求,各家都可能有细微差异。所以接入层要做的不是“假设它们一样”,而是“假设它们大体一样,但为差异留出开关”。
2.2 Agent 循环和普通聊天是两回事
很多人第一次用编码助手会觉得神奇,其实它的核心逻辑非常朴素,就是一个 while 循环:把用户任务和工具定义发给模型,模型如果返回工具调用请求,就在本地执行这个工具,把执行结果作为一条新消息追加到对话历史里,再发回给模型,直到模型不再请求工具、直接给出最终回答为止。
这个循环里最关键的三个设计点是:工具定义要足够清晰(模型靠描述来决定调不调用)、工具执行结果要截断(否则一次读取大文件就能把上下文撑爆)、循环要有最大步数限制(否则模型可能陷入反复调用同一个工具的死循环)。
普通的聊天接口没有这个循环,它只是一问一答。而 Codex 这类产品的体验之所以好,很大程度上不是模型多强,而是这个循环做得稳、工具做得全、错误处理做得好。我们自己搭的时候,只要把这三件事做好,效果不会差太多。
2.3 “积分”在工程上应该被翻译成 token
积分是个营销单位,token 才是工程单位。做接入层时,我建议直接放弃“积分”这个概念,改成记录每次调用的 prompt_tokens 和 completion_tokens,再乘以你自己配置的单价,得到一个估算成本。
这样做的好处是,你能清楚地看到:同一个任务,用 A 模型花了多少、用 B 模型花了多少、降级之后成本变化多少。有了这个数据,你才能判断“免费额度到底够不够用”,而不是凭感觉。
具体价格请以各平台官方页面为准,本文的代码里把价格字段留成可配置项,不写死任何数字。这一点很重要——价格和免费政策是会变的,写死在代码里的数字迟早会变成错误的注释。
3. 环境准备与前置条件
3.1 运行环境
本文示例使用 Python,版本建议 3.10 及以上(因为用到了 str | None 这种类型写法)。依赖只有两个:openai 官方 SDK 和 pyyaml。之所以用官方 SDK 而不是自己拼 HTTP 请求,是因为它在重试、超时、流式解析、工具调用对象的序列化上都做了处理,能省掉大量边角代码。
bash复制# 文件路径:requirements.txt
openai>=1.30.0
pyyaml>=6.0
python-dotenv>=1.0.0
安装方式:
bash复制python -m venv .venv
source .venv/bin/activate # Windows 用 .venv\Scripts\activate
pip install -r requirements.txt
如果你所在的环境不方便创建虚拟环境,至少也要保证依赖版本干净,避免和其他项目的 openai 版本冲突。这个 SDK 在 1.x 版本之间有过几次不兼容改动,混用版本是新手最常见的报错来源之一。
3.2 账号与密钥准备
你需要分别到对应平台的控制台创建 API Key,然后写进环境变量,不要直接写在代码里。下面这张表是常见的环境变量命名约定,你可以按自己的习惯调整,但要保持全项目一致。
| 供应商 | 典型环境变量名 | 说明 |
|---|---|---|
| DeepSeek | DEEPSEEK_API_KEY |
开放平台创建 |
| 智谱 GLM | ZHIPU_API_KEY |
开放平台创建,注意鉴权格式 |
| 通义千问 | DASHSCOPE_API_KEY |
百炼平台创建 |
| 豆包 | ARK_API_KEY |
方舟平台创建,模型名通常是接入点 ID |
bash复制# 文件路径:.env.example(复制为 .env 后填写真实值)
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
ZHIPU_API_KEY=xxxxxxxxxxxxxxxx
DASHSCOPE_API_KEY=sk-xxxxxxxxxxxxxxxx
ARK_API_KEY=xxxxxxxxxxxxxxxx
同时务必把 .env 加进 .gitignore,这是最低限度的安全要求。密钥一旦进了 Git 历史,即使后面删除文件也依然存在于提交记录中,清理成本远比想象中高。
bash复制# 文件路径:.gitignore
.env
.venv/
__pycache__/
workspace/
logs/
关于“免费额度”,这里给一个稳妥的判断:各平台的免费档位、赠送规则、限流阈值都会调整,本文不承诺任何具体额度数字。你要做的是在配置里把免费模型排在路由链的最前面,让日常小任务优先消耗免费额度,付费模型作为兜底。这样无论额度规则怎么变,你的工作流都不会断。
4. 多模型统一接入的核心流程拆解
整套方案分四层,从下往上依次是:配置层、适配层、路由层、应用层。每一层的职责要划清楚,否则代码会很快变成一团乱麻。
4.1 配置层:把“用哪个模型”变成数据
配置层解决的是“模型参数不能硬编码”的问题。每个模型对应一个 ModelSpec,包含逻辑名、供应商、真实模型名、base_url、密钥环境变量名、是否支持工具调用、上下文窗口、单价。这些字段全部来自 YAML 文件,代码里不出现任何具体模型名。
为什么要有“逻辑名”?因为你的业务代码关心的是“我需要一个擅长写代码的模型”,而不是“我需要 deepseek-chat”。逻辑名让路由策略和具体模型解耦,将来换模型只改 YAML,不动业务代码。
4.2 适配层:把差异收敛到一个函数里
适配层只做一件事:根据 ModelSpec 构造一个 OpenAI 客户端实例。看起来简单,但它承担了所有供应商差异的收敛工作。将来如果某家供应商要求额外的 header、特殊的鉴权前缀、或者不同的超时策略,都改在这一个函数里。
这里有个细节值得强调:max_retries=0。官方 SDK 默认会重试两次,而我们的路由层自己实现了重试和降级。两层重试叠加会导致失败时等待时间成倍增长,还会让限流问题雪上加霜。关掉 SDK 的重试,把控制权拿回自己手里。
4.3 路由层:降级、重试、记账三合一
路由层是整套方案的核心。它接收一个“首选模型”和一条降级链,依次尝试:当前模型调用失败就退避重试,重试仍失败就切到下一个模型,直到成功或全部失败。每次成功调用都把 token 用量累加到记账对象里。
降级链的设计要考虑能力差异。比如免费模型可能不支持工具调用,那在需要工具的请求里就应该跳过它,而不是发过去等一个 400 错误。这个判断必须在路由层做,不能让模型层去猜。
重试策略也要分情况:网络错误、超时、限流是可重试的;401 鉴权失败、404 模型不存在、400 参数错误是不可重试的,重试只会浪费时间。这个区分做不好,排错时会非常痛苦。
4.4 应用层:Agent 循环与工具沙箱
应用层就是前面说的 while 循环,加上一组工具实现。工具必须限制在指定的工作目录内,所有路径都要做规范化后再校验前缀,防止 ../../etc/passwd 这类路径穿越。这不是杞人忧天,模型完全可能在推理过程中生成越界路径,尤其是它在处理一些奇怪的文件名时。
工具返回内容还要做长度截断。一次读取几万行的日志文件,如果不截断,下一轮请求就会直接超过上下文窗口,报一个让人摸不着头脑的 400 错误。
5. 完整示例代码实现
5.1 目录结构
先把目录结构定下来,后面所有代码都按这个路径放置,方便你直接复制运行。
text复制project/
├── config/
│ └── models.yaml
├── src/
│ ├── __init__.py
│ ├── cli.py
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── providers.py
│ │ └── router.py
│ └── agent/
│ ├── __init__.py
│ ├── loop.py
│ └── tools.py
├── workspace/
├── .env
└── requirements.txt
5.2 模型配置文件
yaml复制# 文件路径:config/models.yaml
# 注意:base_url 与 model 名称请以各平台官方文档为准,本文只演示结构
router_order:
- coder-free
- general-free
- coder-paid
models:
coder-free:
provider: deepseek
model: deepseek-chat
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
supports_tools: true
context_window: 65536
input_price_per_m: 0.0
output_price_per_m: 0.0
general-free:
provider: zhipu
model: glm-4-flash
base_url: https://open.bigmodel.cn/api/paas/v4
api_key_env: ZHIPU_API_KEY
supports_tools: true
context_window: 131072
input_price_per_m: 0.0
output_price_per_m: 0.0
coder-paid:
provider: dashscope
model: qwen-plus
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key_env: DASHSCOPE_API_KEY
supports_tools: true
context_window: 131072
input_price_per_m: 0.0
output_price_per_m: 0.0
doubao:
provider: volcengine
model: ep-xxxxxxxx-xxxxx
base_url: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
supports_tools: true
context_window: 32768
input_price_per_m: 0.0
output_price_per_m: 0.0
价格字段我全部写成 0.0,这不是偷懒,而是刻意为之:价格是可变的,写死一个数字会让代码变成过期文档。你在实际使用时按官方价目表填进去,成本估算才有意义。
5.3 适配器:构造统一的客户端
python复制# 文件路径:src/llm/providers.py
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
import yaml
from openai import OpenAI
@dataclass(frozen=True)
class ModelSpec:
name: str
provider: str
model: str
base_url: str
api_key_env: str
supports_tools: bool = True
context_window: int = 32768
input_price_per_m: float = 0.0
output_price_per_m: float = 0.0
def build_client(spec: ModelSpec) -> OpenAI:
api_key = os.getenv(spec.api_key_env, "").strip()
if not api_key:
raise RuntimeError(
f"环境变量 {spec.api_key_env} 未设置,无法初始化 {spec.provider} 客户端"
)
return OpenAI(
base_url=spec.base_url,
api_key=api_key,
timeout=60.0,
max_retries=0, # 重试交给路由层统一控制
)
def load_config(path: str = "config/models.yaml") -> tuple[dict[str, ModelSpec], list[str]]:
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
specs: dict[str, ModelSpec] = {}
for name, item in raw["models"].items():
specs[name] = ModelSpec(
name=name,
provider=item["provider"],
model=item["model"],
base_url=item["base_url"],
api_key_env=item["api_key_env"],
supports_tools=bool(item.get("supports_tools", True)),
context_window=int(item.get("context_window", 32768)),
input_price_per_m=float(item.get("input_price_per_m", 0.0)),
output_price_per_m=float(item.get("output_price_per_m", 0.0)),
)
return specs, list(raw["router_order"])
这段代码的关键在于 build_client 把“供应商差异”收敛成了一个函数。如果某家平台以后要求额外请求头,你只需要在这里改一处,所有调用方无感。load_config 把 YAML 转成数据类,让后面的代码可以享受类型提示和 IDE 补全。
5.4 路由层:降级、重试与用量记账
python复制# 文件路径:src/llm/router.py
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Iterable
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError
from src.llm.providers import ModelSpec, build_client
RETRYABLE_ERRORS = (APIConnectionError, APITimeoutError, RateLimitError)
FATAL_STATUS = (400, 401, 403, 404)
@dataclass
class Usage:
calls: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
cost_estimate: float = 0.0
def record(self, prompt: int, completion: int, spec: ModelSpec) -> None:
self.calls += 1
self.prompt_tokens += prompt
self.completion_tokens += completion
self.cost_estimate += (
prompt / 1_000_000 * spec.input_price_per_m
+ completion / 1_000_000 * spec.output_price_per_m
)
def as_dict(self) -> dict[str, Any]:
return {
"calls": self.calls,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"cost_estimate": round(self.cost_estimate, 6),
}
class ModelRouter:
def __init__(self, specs: dict[str, ModelSpec], order: Iterable[str]):
self.specs = specs
self.order = list(order)
self.usage = Usage()
def _chain(self, preferred: str | None) -> list[ModelSpec]:
names = list(self.order)
if preferred and preferred in self.specs:
names = [preferred] + [n for n in names if n != preferred]
return [self.specs[n] for n in names]
def chat(
self,
messages: list[dict[str, Any]],
preferred: str | None = None,
tools: list[dict[str, Any]] | None = None,
temperature: float = 0.2,
max_tokens: int = 2048,
):
last_error: Exception | None = None
for spec in self._chain(preferred):
if tools and not spec.supports_tools:
continue
try:
client = build_client(spec)
except RuntimeError as exc:
last_error = exc
continue
for attempt in range(2):
try:
kwargs: dict[str, Any] = {
"model": spec.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
resp = client.chat.completions.create(**kwargs)
usage = getattr(resp, "usage", None)
self.usage.record(
int(getattr(usage, "prompt_tokens", 0) or 0),
int(getattr(usage, "completion_tokens", 0) or 0),
spec,
)
return spec, resp
except RETRYABLE_ERRORS as exc:
last_error = exc
time.sleep(1.5 * (attempt + 1))
except APIStatusError as exc:
last_error = exc
if exc.status_code in FATAL_STATUS:
break
time.sleep(1.0)
raise RuntimeError(f"所有模型均调用失败,最后一个错误:{last_error}")
这段代码里有三个判断值得单独说。第一,if tools and not spec.supports_tools: continue,这是能力匹配,避免把带工具的请求发给不支持工具的模型。第二,遇到 400/401/403/404 直接 break 跳出当前模型的重试循环,因为这类错误重试多少次结果都一样。第三,密钥缺失被当作“跳过”而不是“崩溃”,这样即使你只配了一家的 key,程序依然能跑起来。
5.5 工具实现:沙箱与截断
python复制# 文件路径:src/agent/tools.py
from __future__ import annotations
from pathlib import Path
WORKSPACE = Path("./workspace").resolve()
MAX_READ_CHARS = 20000
MAX_WRITE_CHARS = 100000
def _safe_path(rel: str) -> Path:
target = (WORKSPACE / rel).resolve()
if not str(target).startswith(str(WORKSPACE)):
raise ValueError(f"路径越界,拒绝访问:{rel}")
return target
def read_file(path: str) -> str:
content = _safe_path(path).read_text(encoding="utf-8")
if len(content) > MAX_READ_CHARS:
return content[:MAX_READ_CHARS] + f"\n...(已截断,原文件 {len(content)} 字符)"
return content
def write_file(path: str, content: str) -> str:
if len(content) > MAX_WRITE_CHARS:
raise ValueError(f"单次写入超限:{len(content)} 字符")
target = _safe_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"已写入 {len(content)} 字符到 {path}"
def list_dir(path: str = ".") -> str:
target = _safe_path(path)
if not target.exists():
return f"目录不存在:{path}"
items = sorted(x.name + ("/" if x.is_dir() else "") for x in target.iterdir())
return "\n".join(items) if items else "(空目录)"
TOOL_IMPLS = {
"read_file": read_file,
"write_file": write_file,
"list_dir": list_dir,
}
TOOLS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取工作区内某个文件的文本内容,超过 20000 字符会被截断",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对于工作区的文件路径"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "把内容写入工作区内的指定文件,目录会自动创建,已存在则覆盖",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对于工作区的文件路径"},
"content": {"type": "string", "description": "要写入的完整文本内容"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "列出工作区内某个目录下的文件和子目录",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对于工作区的目录路径,默认当前目录"}
},
"required": [],
},
},
},
]
_safe_path 是这个文件里最重要的函数。它先 resolve() 把 .. 和符号链接全部展开成绝对路径,再判断结果是否以工作区路径开头。顺序不能反,如果先判断再 resolve,foo/../../etc 这类路径就能绕过检查。
5.6 Agent 循环
python复制# 文件路径:src/agent/loop.py
from __future__ import annotations
import json
from typing import Any
from src.llm.router import ModelRouter
from src.agent.tools import TOOL_IMPLS, TOOLS
SYSTEM_PROMPT = """你是一个在本地工作区中工作的编码助手。
规则:
1. 需要了解文件内容时,先调用 list_dir 和 read_file,不要凭空猜测。
2. 修改文件必须调用 write_file,并给出完整文件内容,不要只给片段。
3. 每次只做一件小事,完成后简要说明你改了什么。
4. 不要生成任何超出工作区范围的路径。"""
def run(
router: ModelRouter,
task: str,
preferred: str | None = None,
max_steps: int = 10,
) -> str:
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task},
]
for step in range(1, max_steps + 1):
spec, resp = router.chat(messages, preferred=preferred, tools=TOOLS)
msg = resp.choices[0].message
print(f"[step {step}] 使用模型:{spec.name} ({spec.provider}/{spec.model})")
tool_calls = getattr(msg, "tool_calls", None)
if not tool_calls:
return msg.content or ""
messages.append(msg.model_dump())
for call in tool_calls:
name = call.function.name
try:
args = json.loads(call.function.arguments or "{}")
result = TOOL_IMPLS[name](**args)
except KeyError:
result = f"未知工具:{name}"
except Exception as exc: # 工具异常不能让整个循环崩掉
result = f"工具执行失败:{exc}"
print(f" -> {name}({call.function.arguments}) = {str(result)[:120]}")
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": str(result)[:8000],
}
)
return f"达到最大步数 {max_steps},任务未完成,请检查工具描述或拆分任务"
注意 msg.model_dump() 这一步,它把 SDK 返回的消息对象转成普通字典再追加进历史。如果不转换直接 append 对象,部分模型会因为有非标准字段而拒绝请求。这是一个非常隐蔽的坑,表现是“第一次调用成功,第二轮就 400”。
5.7 命令行入口
python复制# 文件路径:src/cli.py
from __future__ import annotations
import argparse
import json
from dotenv import load_dotenv
from src.llm.providers import load_config
from src.llm.router import ModelRouter
from src.agent.loop import run
def main() -> None:
parser = argparse.ArgumentParser(description="多模型编码 Agent")
parser.add_argument("--task", required=True, help="要执行的任务描述")
parser.add_argument("--model", default=None, help="首选模型逻辑名")
parser.add_argument("--max-steps", type=int, default=10)
args = parser.parse_args()
load_dotenv()
specs, order = load_config()
router = ModelRouter(specs, order)
answer = run(router, args.task, preferred=args.model, max_steps=args.max_steps)
print("\n===== 最终结果 =====")
print(answer)
print("\n===== 用量统计 =====")
print(json.dumps(router.usage.as_dict(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
6. 运行结果与效果验证
先把工作区准备好,放一个待处理的项目进去:
bash复制mkdir -p workspace/demo
cat > workspace/demo/calc.py <<'EOF'
def add(a, b):
return a - b
EOF
然后执行一个典型任务:
bash复制python -m src.cli --task "检查 workspace/demo/calc.py 里的 add 函数是否正确,如果有 bug 就修复它,并补充一个简单的测试文件"
预期输出大致是这样的结构:
text复制[step 1] 使用模型:coder-free (deepseek/deepseek-chat)
-> list_dir({"path": "demo"}) = calc.py
[step 2] 使用模型:coder-free (deepseek/deepseek-chat)
-> read_file({"path": "demo/calc.py"}) = def add(a, b):\n return a - b\n
[step 3] 使用模型:coder-free (deepseek/deepseek-chat)
-> write_file({"path": "demo/calc.py", "content": "..."}) = 已写入 62 字符到 demo/calc.py
[step 4] 使用模型:coder-free (deepseek/deepseek-chat)
-> write_file({"path": "demo/test_calc.py", "content": "..."}) = 已写入 145 字符到 demo/test_calc.py
[step 5] 使用模型:coder-free (deepseek/deepseek-chat)
===== 最终结果 =====
已修复 add 函数中的减法错误改为加法,并新增 test_calc.py 覆盖正数、负数和零三种情况。
===== 用量统计 =====
{
"calls": 5,
"prompt_tokens": 3871,
"completion_tokens": 612,
"cost_estimate": 0.0
}
判断成功的三个标准:一是 workspace/demo/calc.py 里的 a - b 变成了 a + b;二是多了一个 test_calc.py;三是用量统计里 calls 是有限次、prompt_tokens 没有异常暴涨。
如果 calls 一直涨到 max_steps 还没结束,说明模型在空转,通常是工具描述写得不够清楚,或者任务本身太笼统。这时不要急着换模型,先改工具描述,把“什么时候该调用”讲明白。
想验证降级是否生效,可以把 DEEPSEEK_API_KEY 故意写错,再跑一次。你应该能看到错误被捕获、自动切到下一个模型、任务依然完成。这个测试比任何文档都直观。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 401 Unauthorized | 密钥未加载或已失效 | 打印环境变量长度确认非空;检查 .env 是否被 load |
重新生成密钥,确认 load_dotenv() 在读取配置前调用 |
| 404 model not found | 模型名不匹配,或豆包类平台需要接入点 ID | 对比官方文档的模型标识 | 修改 YAML 中 model 字段,豆包填推理接入点 ID |
| 400 参数错误 | 模型不支持 tools,或 max_tokens 超限 |
查看错误体里的 message 字段 | 把该模型 supports_tools 设为 false;调低 max_tokens |
| 第二轮请求报错 | 消息对象未序列化为字典 | 检查是否直接 append 了 SDK 消息对象 | 使用 msg.model_dump() 转换 |
| 429 限流 | 免费档位并发或频次受限 | 观察错误发生的时间分布 | 降级链条里加备用模型,重试加指数退避 |
| 上下文超限 | 读取文件未截断、历史消息无限增长 | 打印每轮 messages 的总长度 | 降低 MAX_READ_CHARS;对历史做裁剪或摘要 |
| 工具调用死循环 | 工具描述含糊,模型反复试探 | 看日志里重复的调用参数 | 改进工具描述,收紧 max_steps,在提示词里明确禁止重复调用 |
| 路径越界异常 | 模型生成了相对路径之外的路径 | 查看异常里的 rel 参数 |
该异常本身就是保护机制,检查提示词是否引导了错误路径 |
| 中文乱码 | 读写文件未指定编码 | 检查是否传了 encoding="utf-8" |
统一显式指定 UTF-8 |
| 响应很慢 | 单轮上下文过大或网络链路问题 | 对比不同模型的耗时 | 减少历史消息,把长任务拆成多个短任务 |
排错有一个通用顺序建议:先看用量统计里的 calls 和 token 数,判断是调用次数异常还是单次体积异常;再看日志里最后一条成功的工具调用,定位卡在哪一步;最后才去怀疑模型本身。绝大多数问题出在前两步,而不是模型能力。
8. 最佳实践与工程建议
8.1 密钥与配置管理
密钥只走环境变量,绝不进代码、不进 Git、不进日志。如果你在多台机器上跑,考虑用系统密钥管理工具或者云厂商的密钥服务,而不是把 .env 复制来复制去。日志里如果一定要打印配置,记得把密钥字段脱敏成只显示后四位。
配置文件的版本管理要谨慎。models.yaml 本身可以进 Git,但里面不要写任何密钥。模型名、地址、单价这类信息进 Git 反而是好事,因为可以追溯“哪次改动导致成本上升”。
8.2 降级链的设计原则
降级链不是随便排的。正确的排法是:能力匹配优先于价格。需要工具调用的任务,链里不能出现不支持工具的模型;需要长上下文的任务,链里不能出现窗口过小的模型。在满足能力的前提下,再按价格和额度从低到高排。
另外建议准备一条“全挂兜底”路径。当所有模型都失败时,程序应该明确报错并保留现场(把当前 messages 落盘),而不是静默返回空字符串。调试时这份现场比什么都值钱。
8.3 成本与额度的可观测性
把用量统计按天写到日志文件里,格式用 JSON Lines,一行一条。这样你可以用最简单的 jq 或 Python 脚本统计“今天调了多少次、花了多少、哪个模型用得最多”。没有这层数据,“免费额度够不够用”这个问题永远只能靠感觉回答。
建议给单次任务设一个 token 预算上限。一旦某次任务的累计 token 超过阈值,就中止并提示用户拆分任务。这比事后看账单要友好得多。
8.4 工具沙箱与写操作确认
write_file 是危险操作,尤其是当模型误判文件内容时,覆盖是不可逆的。生产环境里建议加两道防线:一是写操作前自动备份原文件到 .backup/ 目录,二是对超过一定行数的写入要求人工确认。如果你只是自己本地用,至少也要把工作区限制在一个专门的目录里,不要指向你的家目录或代码主仓库。
还有一个容易忽略的点:不要在 Agent 的工具集里放执行任意 shell 命令的能力,除非你有很强的隔离手段。文件读写已经能覆盖大部分编码任务,加一个 run_tests 这样的白名单命令比开放 shell 安全得多。
8.5 提示词与工具描述的维护
工具描述实际上是给模型看的“API 文档”,它的质量直接决定调用准确率。建议把工具描述当成代码来维护:改一次描述,跑一遍固定的验收任务,看调用轮数有没有下降。这本质上就是在做小规模的提示词回归测试。
系统提示词也不要写太长。规则超过十条之后,模型的遵从率会明显下降。宁可把复杂规则拆到工具描述里,也不要在 system 消息里堆一大段。
9. 这套方案适合谁,以及下一步该往哪走
先说适合的人。如果你日常要处理的是“读懂一个陌生模块”“给现有函数补测试”“把一段逻辑从 A 风格改成 B 风格”这类中等粒度的编码任务,并且希望成本可控、代码不出本地,那这套自建 Agent 是很划算的。它的学习曲线不陡,核心代码加起来不到三百行,改起来也自由。
再说不太适合的场景。超大仓库的跨文件重构、需要长时间自主探索的任务、需要精确语义检索的海量代码库,这些场景对上下文管理、索引、检索增强的要求高得多,不是几百行代码能解决的。这类需求更适合用成熟产品,或者在这套骨架之上再补一层代码索引和检索。
关于“免费额度”这件事,最后给一个务实的判断:免费额度是很好的入口,它让你能以接近零的成本把工作流跑通、把提示词调好、把工具描述打磨清楚。但当任务变重之后,你会自然地从“找免费额度”转向“找性价比最高的模型组合”。而这个转变能顺利发生的前提,正是你手里有一层可以随时替换模型的接入代码。
下一步值得深入的方向有三个。第一个是给 Agent 加上代码检索能力,用简单的关键词索引配合文件摘要,就能显著减少无效的 read_file 调用。第二个是把验收任务固化成一组测试用例,每次改提示词或换模型都跑一遍,用数据而不是体感来判断效果。第三个是给写操作加 diff 预览和回滚,让 Agent 的改动变得可审查。
先把最小闭环跑通,再逐层加能力。模型会一直换,接入层的价值不会。
