本文是AI Agent 架构实战:从 Demo 到业务系统系列第 6 篇。前面几篇讲了上下文工程、工具边界、任务状态机和长任务体验,这一篇继续往底层走:当 Agent 真正开始跑任务之后,模型调用怎么稳定、怎么可控、怎么省钱、怎么兜底。
很多 Agent Demo 里,模型调用通常是一段很直接的代码:
response = await client.chat.completions.create(...)
这当然能跑。
但真实业务系统里,问题很快就会变多:
写正文、做质检、拆大纲、提取记忆,要不要用同一个模型?
模型返回空内容怎么办?
JSON 解析失败怎么办?
用户连续点 20 次 AI 按钮怎么办?
一次自动写作到底花了多少钱?
测试环境会不会误打到真实模型?
模型配置改了,服务要不要重启?
这些问题单看都不大。
但放到 AI Agent 系统里,它们会一起决定一件事:
Agent 到底是一个能长期跑的业务能力,还是一段靠运气工作的模型调用代码。
这一篇我继续结合当前的 AI 小说创作系统,拆一下我在项目里做的 AI 工程底座。
01. 先给结论
我现在不建议在业务代码里到处直接调用大模型。
更稳的做法是把模型调用收敛成一条链路:
| 层级 | 解决什么问题 |
|---|---|
| 配置层 | provider、base url、默认模型、测试模式 |
| 路由层 | 不同任务类型选择不同模型 |
| 网关层 | 文本、JSON、工具调用统一出口 |
| 防护层 | 限流、超时、空响应重试、安全测试模式 |
| 观测层 | 日志、token 估算、成本记录 |
| 协议层 | 错误返回告诉前端下一步动作 |
对应到系统里,大概是这样:
业务服务
↓
chat_text / chat_json / chat_json_with_tools
↓
get_model_for_task(task)
↓
AsyncOpenAI(OpenAI-compatible)
↓
日志 / 成本 / 错误协议 / 限流
这篇不是讲“怎么接入某个模型厂商”。
我更想讲的是:
当你要把 Agent 做成产品,模型调用周围必须长出哪些工程能力。
02. 为什么不能把模型调用散落在业务里
一开始做 AI 功能,很容易写成这样:
质检接口里调一次模型
大纲接口里调一次模型
章节生成里调一次模型
人物生成里调一次模型
导出简介里调一次模型
每个地方都自己处理:
model
temperature
max_tokens
timeout
JSON 解析
异常捕获
日志记录
成本统计
短期看很快。
长期看会出现几个问题。
| 问题 | 后果 |
|---|---|
| 模型选择分散 | 换模型要全项目搜索 |
| JSON 解析分散 | 每个接口都有自己的脆弱兜底 |
| 异常格式分散 | 前端不知道失败后该刷新、重试还是跳转 |
| 成本不可见 | 自动写作跑多了之后不知道钱花在哪里 |
| 测试不安全 | E2E 或本地测试可能误打真实模型 |
所以我在项目里做的第一件事,就是把模型调用收敛到统一网关。
业务层不关心具体 provider。
业务层只表达:
我要做 writing
我要做 planning
我要做 quality
我要拿 JSON
我要允许工具调用
底座负责把这些意图翻译成具体模型调用。
03. 配置层:provider 不是散落的字符串
项目里所有 AI 配置都集中在 app/core/config.py。
MODEL_PRESETS = {
"xfyun": {
"ai_api_base": "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2",
"ai_model": "astron-code-latest",
},
"deepseek": {
"ai_api_base": "https://api.deepseek.com",
"ai_model": "deepseek-v4-pro",
},
"openai": {
"ai_api_base": "https://api.openai.com/v1",
"ai_model": "gpt-4o",
},
}
配置对象只保留通用字段:
class Settings(BaseSettings):
"""应用全局配置,自动读取 .env / 环境变量"""
database_url: str = "sqlite+aiosqlite:///./novel_system.db"
ai_provider: str = "xfyun"
ai_api_base: str = ""
ai_api_key: str = ""
ai_model: str = ""
ai_test_mode: bool = False
model_config = {
"env_file": str(_ENV_PATH),
"env_file_encoding": "utf-8",
"extra": "ignore",
}
def apply_preset(self):
"""根据 ai_provider 应用预设(仅当未手动设置时覆盖)"""
preset = MODEL_PRESETS.get(self.ai_provider, {})
if not self.ai_api_base:
self.ai_api_base = preset.get("ai_api_base", "")
if not self.ai_model:
self.ai_model = preset.get("ai_model", "")
这里有一个细节:ai_api_base 和 ai_model 可以被 .env 手动覆盖。
也就是说:
默认:provider 决定 base url 和 model
高级:用户可以覆盖 base url 和 model
这对自托管产品很重要。
因为不同用户可能会接:
DeepSeek
OpenAI
讯飞星辰
本地 OpenAI-compatible 服务
公司内部模型网关
如果 provider、base url、model 到处写死,后面一定会痛。
04. 热重载:模型配置改完不应该重启服务
自托管系统里,模型配置通常会在管理页面改。
如果用户每次改完都要重启后端,体验会很割裂。
所以系统提供了一个 reload_settings():
def reload_settings():
"""热重载配置:重新读取 .env 并更新全局 settings 对象,无需重启"""
if not _ENV_PATH.exists():
return
env = {}
for line in _ENV_PATH.read_text(encoding="utf-8").split("\n"):
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
settings.ai_provider = env.get("AI_PROVIDER", "xfyun")
settings.ai_api_base = env.get("AI_API_BASE", "")
settings.ai_api_key = env.get("AI_API_KEY", "")
settings.ai_model = env.get("AI_MODEL", "")
settings.ai_test_mode = os.getenv(
"AI_TEST_MODE",
env.get("AI_TEST_MODE", ""),
).strip().lower() in {"1", "true", "yes", "on"}
preset = MODEL_PRESETS.get(settings.ai_provider, {})
if not settings.ai_api_base and preset.get("ai_api_base"):
settings.ai_api_base = preset["ai_api_base"]
if not settings.ai_model and preset.get("ai_model"):
settings.ai_model = preset["ai_model"]
管理接口保存配置后,会做两件事:
@router.put("/model-config")
async def update_model_config(config: ModelConfig):
"""更新模型配置 — 保存后即时生效,无需重启后端"""
updates = {}
preset = PRESETS.get(config.provider)
if config.provider and config.provider != "custom" and preset:
updates["AI_PROVIDER"] = config.provider
api_base = preset.get("ai_api_base", "")
if api_base:
updates["AI_API_BASE"] = api_base
default_model = preset.get("ai_model", "")
available_models = [default_model] if default_model else []
chosen_model = config.model if config.model in available_models else default_model
if chosen_model:
updates["AI_MODEL"] = chosen_model
if config.api_key and "***" not in config.api_key:
updates["AI_API_KEY"] = config.api_key
_write_env(updates)
try:
reload_settings()
from app.core.ai_config import reset_ai_client
await reset_ai_client()
reload_ok = True
except Exception:
reload_ok = False
return {
"ok": True,
"updated": list(updates.keys()),
"model_set": updates.get("AI_MODEL", ""),
"hot_reload": reload_ok,
}
这里不只是改 .env。
关键是保存后调用:
reload_settings()
reset_ai_client()
因为 OpenAI SDK client 内部持有 base url、api key 和 http transport。
如果只改 settings,不重建 client,后续请求仍然可能走旧配置。
05. 模型路由:不是所有任务都该用同一个模型
Agent 系统里,不同任务对模型能力的要求不一样。
比如小说系统里:
| 任务 | 更看重什么 |
|---|---|
| 正文生成 | 速度、成本、稳定输出 |
| 质量审核 | 推理、结构化判断、发现问题 |
| 记忆提取 | 信息抽取准确性 |
| 创意发散 | 想象力、推理深度 |
| 大纲规划 | 长上下文理解和结构能力 |
如果全部用最强模型,成本会失控。
如果全部用最快模型,质检和规划可能不可靠。
所以配置里有一个任务到模型的映射:
_PROVIDER_TASK_MODEL_MAP = {
"deepseek": {
"analysis": "deepseek-v4-pro",
"review": "deepseek-v4-pro",
"quality": "deepseek-v4-pro",
"extract": "deepseek-v4-pro",
"writing": "deepseek-v4-flash",
"generate": "deepseek-v4-flash",
"draft": "deepseek-v4-flash",
"expand": "deepseek-v4-flash",
"creative": "deepseek-reasoner",
"brainstorm": "deepseek-reasoner",
"plot": "deepseek-reasoner",
},
"xfyun": {
"analysis": "astron-code-latest",
"review": "astron-code-latest",
"quality": "astron-code-latest",
"extract": "astron-code-latest",
"writing": "astron-code-latest",
"generate": "astron-code-latest",
"draft": "astron-code-latest",
"expand": "astron-code-latest",
"creative": "astron-code-latest",
"brainstorm": "astron-code-latest",
"plot": "astron-code-latest",
},
}
真正给业务用的是一个小函数:
def get_model_for_task(task: str | None) -> str:
"""根据任务类型返回对应模型,未匹配时用全局默认模型"""
task_map = _get_task_model_map()
if task and task in task_map:
return task_map[task]
return settings.ai_model
业务代码不写死模型名。
它只传任务意图:
data = await chat_json(
system_prompt,
user_prompt,
temperature=0.3,
max_tokens=1500,
task="quality",
novel_id=novel_id,
chapter_id=chapter_id,
)
这样将来要改策略时,只改路由表。
这也是架构师视角里很重要的一点:
模型选择是策略,不应该散落成业务代码里的字符串。
06. 统一网关:业务层只调用 chat_text / chat_json / chat_json_with_tools
模型路由只是第一步。
还需要统一出口。
项目里统一出口在 app/services/ai_gateway.py。
文本调用是最基础的:
async def chat_text(
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.3,
max_tokens: int = 2000,
timeout: float | None = None,
task: str | None = None,
novel_id: int | None = None,
chapter_id: int | None = None,
response_format: dict[str, Any] | None = None,
) -> str:
client = get_ai_client()
model = get_model_for_task(task)
start = time.perf_counter()
meta = _meta(system_prompt, user_prompt, max_tokens, temperature)
meta["model"] = model
request_kwargs: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if timeout is not None:
request_kwargs["timeout"] = timeout
if response_format is not None:
request_kwargs["response_format"] = response_format
这里统一处理了:
client 获取
模型路由
messages 组装
temperature / max_tokens / timeout
日志 meta
异常包装
空内容重试
成本记录
业务层不再重复写这些东西。
它只要关心本次调用要解决什么问题。
07. 空响应重试:小兜底能省很多脏状态
模型调用不是每次都稳定。
有时 provider 会返回成功响应,但 content 是空。
如果业务层直接拿空内容继续走,后面可能会变成:
空正文提案
空质检结果
JSON 解析失败
AgentStep 显示成功但结果不可用
所以网关里加了一个很小的兜底:
EMPTY_COMPLETION_ATTEMPTS = 2
content: str | None = None
for attempt in range(EMPTY_COMPLETION_ATTEMPTS):
try:
response = await client.chat.completions.create(**request_kwargs)
except Exception as exc:
logger.warning(
"ai.chat_text.failed",
extra={
"ai": {
**meta,
"duration_ms": round((time.perf_counter() - start) * 1000, 1),
"error": type(exc).__name__,
}
},
)
raise AIServiceError(f"AI 调用失败: {exc}") from exc
candidate = response.choices[0].message.content if response.choices else None
if candidate and candidate.strip():
content = candidate
break
logger.warning(
"ai.chat_text.empty",
extra={
"ai": {
**meta,
"attempt": attempt + 1,
"will_retry": attempt + 1 < EMPTY_COMPLETION_ATTEMPTS,
}
},
)
if attempt + 1 < EMPTY_COMPLETION_ATTEMPTS:
await asyncio.sleep(0.2)
if content is None:
raise AIServiceError("AI 返回内容为空")
测试也很直接:
@pytest.mark.asyncio
async def test_chat_text_retries_once_when_provider_returns_an_empty_completion(monkeypatch):
client = SequencedClient([None, "重试后正文"])
monkeypatch.setattr(ai_gateway, "get_ai_client", lambda: client)
monkeypatch.setattr(ai_gateway, "get_model_for_task", lambda task: "test-model")
result = await ai_gateway.chat_text("系统提示", "用户请求")
assert result == "重试后正文"
assert len(client.completions.calls) == 2
assert client.completions.calls[0] == client.completions.calls[1]
这不是复杂逻辑。
但它可以避免很多偶发模型问题向业务层扩散。
08. JSON 调用:结构化输出要收在统一入口
Agent 系统里大量任务都需要 JSON:
质检结果
章节计划
场景拆解
记忆提取
工具调用最终答案
如果每个业务模块都自己解析 JSON,就会出现很多重复代码。
所以网关里有一个 extract_json_object():
def extract_json_object(raw: str) -> dict[str, Any]:
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\{[\s\S]*\}", text)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
return {"raw": raw, "error": "JSON 解析失败"}
然后 chat_json() 只做一件事:在 chat_text() 之上增加结构化约束和解析。
async def chat_json(
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.3,
max_tokens: int = 2000,
timeout: float | None = None,
task: str | None = None,
novel_id: int | None = None,
chapter_id: int | None = None,
) -> dict[str, Any]:
json_system_prompt = system_prompt
response_format = None
if settings.ai_provider == "deepseek":
json_system_prompt = (
f'{system_prompt}\n\n只返回合法 JSON 对象,不要 Markdown,不要额外说明,示例 {{"scenes": []}}'
)
response_format = {"type": "json_object"}
data = extract_json_object(await chat_text(
json_system_prompt,
user_prompt,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
task=task,
novel_id=novel_id,
chapter_id=chapter_id,
response_format=response_format,
))
if data.get("error"):
raise AIServiceError(f"AI 返回内容无法解析为 JSON: {data.get('error')}")
return data
注意这里还处理了 provider 差异。
比如 DeepSeek 场景下,会额外传:
response_format = {"type": "json_object"}
并在 system prompt 里补充:
只返回合法 JSON 对象,不要 Markdown,不要额外说明
对应测试:
@pytest.mark.asyncio
async def test_chat_json_uses_deepseek_json_mode_and_appends_json_constraint(monkeypatch):
client = FakeClient()
monkeypatch.setattr(ai_gateway.settings, "ai_provider", "deepseek")
monkeypatch.setattr(ai_gateway, "get_ai_client", lambda: client)
monkeypatch.setattr(ai_gateway, "get_model_for_task", lambda task: "test-model")
result = await ai_gateway.chat_json("原始系统提示", "用户请求")
request = client.completions.calls[0]
assert result == {"scenes": []}
assert request["response_format"] == {"type": "json_object"}
system_prompt = request["messages"][0]["content"]
assert "原始系统提示" in system_prompt
assert "只返回合法 JSON 对象" in system_prompt
这里的设计原则是:
provider 差异应该在网关层消化,不应该让业务层到处判断当前是哪家模型。
09. 工具调用:Tool Calls 也要有统一循环
前面第 3 篇讲过工具调用边界。
但工具调用本身也应该走统一网关。
项目里有一个 chat_json_with_tools():
async def chat_json_with_tools(
system_prompt: str,
user_prompt: str,
*,
tools: list[dict[str, Any]],
tool_handler: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]],
temperature: float = 0.3,
max_tokens: int = 2000,
timeout: float | None = None,
task: str | None = None,
novel_id: int | None = None,
chapter_id: int | None = None,
max_rounds: int = 4,
) -> dict[str, Any]:
"""Run an OpenAI-compatible tool-call loop and parse the final JSON answer."""
client = get_ai_client()
model = get_model_for_task(task)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
tool_trace: list[dict[str, Any]] = []
核心循环是:
for round_index in range(max_rounds):
request_kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"tools": tools,
"tool_choice": "auto",
}
response = await client.chat.completions.create(**request_kwargs)
message = response.choices[0].message if response.choices else None
if not message:
raise AIServiceError("AI 工具调用返回为空")
tool_calls = list(getattr(message, "tool_calls", None) or [])
if not tool_calls:
content = message.content or ""
data = extract_json_object(content)
data["_tool_calls"] = tool_trace
return data
assistant_tool_calls = []
for tool_call in tool_calls:
function = getattr(tool_call, "function", None)
name = getattr(function, "name", "")
arguments_raw = getattr(function, "arguments", "{}") or "{}"
assistant_tool_calls.append(
{
"id": tool_call.id,
"type": "function",
"function": {"name": name, "arguments": arguments_raw},
}
)
messages.append({
"role": "assistant",
"content": message.content or "",
"tool_calls": assistant_tool_calls,
})
for tool_call in tool_calls:
function = getattr(tool_call, "function", None)
name = getattr(function, "name", "")
arguments_raw = getattr(function, "arguments", "{}") or "{}"
arguments = json.loads(arguments_raw)
result = await tool_handler(name, arguments)
tool_trace.append({
"round": round_index + 1,
"name": name,
"arguments": arguments,
"result": result,
})
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
})
raise AIServiceError("AI 工具调用轮次超限")
这里我会特别保留 _tool_calls。
因为 Agent 工具调用不是黑箱,它应该能被追踪:
调用了哪个工具
传了什么参数
工具返回了什么
跑了几轮
最终回答是什么
这对排查 Agent 行为非常关键。
没有 trace,后面用户问“为什么 Agent 这么写”,你只能猜。
10. 测试模式:不要让测试误打真实模型
这一点我觉得非常重要。
Agent 系统一旦进入 E2E 测试,很容易出现一个危险情况:
测试本来应该打 fake OpenAI
配置不小心变成真实 API
CI 或本地测试开始调用真实模型
这不只是花钱问题。
还可能把测试数据发到外部服务。
所以系统里有一个 AI_TEST_MODE 安全边界。
class AITestModeError(RuntimeError):
"""Raised before a test-mode client can reach a non-local network."""
def validate_test_ai_base_url(base_url: str, *, test_mode: bool) -> str:
"""Return an explicitly local test endpoint or fail closed."""
if not test_mode:
return base_url
try:
parsed = urlparse(base_url)
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.hostname is None
or parsed.path == ""
):
raise ValueError("invalid")
host = parsed.hostname.rstrip(".").lower()
loopback = host == "localhost"
if not loopback:
loopback = ipaddress.ip_address(host).is_loopback
if not loopback:
raise ValueError("not-loopback")
except (ValueError, TypeError):
raise AITestModeError("ai_test_mode_base_url_invalid") from None
return base_url
创建 client 前强制校验:
def get_ai_client() -> AsyncOpenAI:
"""Get or create the shared AsyncOpenAI client."""
global _client, _http_client
if _client is None:
if not settings.ai_api_key:
raise RuntimeError("AI_API_KEY 未设置!请在 .env 文件中设置 AI_API_KEY")
validate_test_ai_base_url(
settings.ai_api_base,
test_mode=settings.ai_test_mode,
)
if settings.ai_test_mode:
_http_client = httpx.AsyncClient(
follow_redirects=False,
trust_env=False,
)
else:
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=600.0, write=600.0, pool=600.0),
limits=httpx.Limits(
max_connections=1000,
max_keepalive_connections=100,
keepalive_expiry=5.0,
),
follow_redirects=True,
)
测试也明确表达了这个边界:
def test_test_mode_rejects_non_loopback_ai_base_url() -> None:
"""A real E2E run must never silently fall back to a public AI host."""
with pytest.raises(AITestModeError, match="ai_test_mode_base_url_invalid"):
validate_test_ai_base_url("https://api.openai.com/v1", test_mode=True)
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:11434/v1",
"http://localhost:11434/v1",
"http://[::1]:11434/v1",
],
)
def test_test_mode_accepts_explicit_loopback_fake_server(url: str) -> None:
assert validate_test_ai_base_url(url, test_mode=True) == url
这里还有一个小细节:
trust_env=False
follow_redirects=False
测试模式下不继承代理环境,也不跟随重定向。
目的很简单:
测试模式必须 fail closed,而不是“尽量试试”。
11. 限流:不要等模型账单爆了才想起来防抖
AI 接口和普通 CRUD 接口不一样。
一次请求背后可能是:
大上下文拼接
模型调用
工具循环
写入提案
触发后续记忆提取
所以限流不能只靠前端按钮 disabled。
后端需要一道最外层防线。
项目里用的是一个简单的 token-bucket middleware:
AI_PATH_PREFIXES = (
"/api/v1/copilot",
"/api/v1/ai-write",
"/api/v1/auto-write",
"/api/v1/ai-engine",
"/api/v1/analysis",
"/api/v1/evolve-outline",
"/api/v1/scene-generate",
"/api/v1/world-building",
"/api/v1/emotion-curve",
"/api/v1/book-dismantle",
"/api/v1/writers-workshop",
"/api/v1/quality-gate",
)
核心实现:
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Token-bucket per-IP rate limiter for AI endpoints."""
def __init__(self, app, max_requests: int = None, window_seconds: int = None):
super().__init__(app)
self.max_requests = max_requests or int(os.getenv("RATE_LIMIT_MAX_REQUESTS", "60"))
self.window_seconds = window_seconds or int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60"))
self._buckets: dict[str, list[float]] = defaultdict(list)
async def dispatch(self, request, call_next):
path = request.url.path
if not path.startswith(AI_PATH_PREFIXES):
return await call_next(request)
ip = request.client.host if request.client else "unknown"
now = time.time()
cutoff = now - self.window_seconds
bucket = self._buckets[ip]
self._buckets[ip] = [t for t in bucket if t > cutoff]
if len(self._buckets[ip]) >= self.max_requests:
return JSONResponse(
status_code=429,
content={
"detail": f"请求过于频繁,请稍后再试({self.max_requests}次/{self.window_seconds}秒)",
"retry_after": int(self.window_seconds - (now - min(self._buckets[ip]))),
},
)
self._buckets[ip].append(now)
return await call_next(request)
这个实现不复杂,但它解决了一个很现实的问题:
用户误点
页面重复提交
脚本刷接口
前端 bug 导致循环请求
尤其是自托管产品,默认没有复杂账号体系。
那至少要有 IP 级别的 AI 接口限流。
12. 成本追踪:Agent 不能只关心效果,也要关心账单
Agent 系统的成本不是一次调用的成本。
而是一条任务链路的成本:
上下文构建
章节规划
草稿生成
质量审核
自动修复
记忆提取
如果没有成本记录,你只能知道“今天花得有点多”。
但你不知道钱花在:
哪个作品
哪个章节
哪个模型
哪类任务
所以系统里有 cost_logs 表:
class CostLog(Base):
"""AI 调用成本记录 —— 追踪每次 API 调用的 token 消耗和费用"""
__tablename__ = "cost_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
novel_id = Column(Integer, ForeignKey("novels.id"), nullable=False, index=True)
chapter_id = Column(Integer, ForeignKey("chapters.id"), nullable=True)
model = Column(String(50), nullable=False)
task_type = Column(String(30), default="writing")
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
cost_rmb = Column(Float, default=0.0)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
当前版本里 token 是估算的:
PRICING = {
"deepseek-chat": {"input": 1.0, "output": 2.0},
"deepseek-v4-flash": {"input": 1.0, "output": 2.0},
"deepseek-v4-pro": {"input": 2.0, "output": 8.0},
"deepseek-reasoner": {"input": 4.0, "output": 16.0},
"default": {"input": 2.0, "output": 8.0},
}
CN_CHARS_PER_TOKEN = 1.8
def estimate_tokens(text: str) -> int:
"""从文本字符数估算 token 数"""
if not text:
return 0
return max(1, int(len(text) / CN_CHARS_PER_TOKEN))
def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""计算费用(人民币元)"""
pricing = PRICING.get(model, PRICING["default"])
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
return round(input_cost + output_cost, 6)
记录一次调用:
async def log_cost(
db: AsyncSession,
*,
novel_id: int,
chapter_id: int | None = None,
model: str,
task_type: str = "writing",
prompt_text: str = "",
completion_text: str = "",
) -> CostLog:
"""记录一次 AI 调用的成本"""
prompt_tokens = estimate_tokens(prompt_text)
completion_tokens = estimate_tokens(completion_text)
cost = calculate_cost(model, prompt_tokens, completion_tokens)
log_entry = CostLog(
novel_id=novel_id,
chapter_id=chapter_id,
model=model,
task_type=task_type,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cost_rmb=cost,
created_at=datetime.utcnow(),
)
db.add(log_entry)
await db.commit()
return log_entry
chat_text() 里会自动接上成本记录:
if novel_id:
from app.services.cost_tracker import log_cost
from app.core.database import async_session
async with async_session() as db:
try:
await log_cost(
db,
novel_id=novel_id,
chapter_id=chapter_id,
model=model,
task_type=task or "default",
prompt_text=(system_prompt or "") + (user_prompt or ""),
completion_text=content,
)
except Exception:
pass
这里有一个取舍:成本记录失败不影响主流程。
因为对作者来说,生成提案成功比成本日志成功更重要。
但成本日志要尽量记录,用于后面做统计。
13. 成本 API:不要只有日志,要能被产品使用
成本记录落库之后,还要能被页面或运营视角使用。
系统提供了两个 API:
router = APIRouter(prefix="/api/v1/costs", tags=["costs"])
@router.get("/novel/{novel_id}")
async def novel_cost_summary(
novel_id: int,
db: AsyncSession = Depends(get_db),
):
"""作品成本总览"""
return await get_novel_cost_summary(db, novel_id)
@router.get("/novel/{novel_id}/chapter/{chapter_id}")
async def chapter_cost_detail(
novel_id: int,
chapter_id: int,
db: AsyncSession = Depends(get_db),
):
"""单章成本明细"""
return await get_chapter_costs(db, novel_id, chapter_id)
作品成本汇总会按模型和任务类型分组:
async def get_novel_cost_summary(db: AsyncSession, novel_id: int) -> dict:
total_row = await db.execute(
select(
func.count(CostLog.id),
func.sum(CostLog.prompt_tokens),
func.sum(CostLog.completion_tokens),
func.sum(CostLog.cost_rmb),
).where(CostLog.novel_id == novel_id)
)
count, total_prompt, total_completion, total_cost = total_row.one()
by_model_rows = await db.execute(
select(
CostLog.model,
func.count(CostLog.id),
func.sum(CostLog.cost_rmb),
)
.where(CostLog.novel_id == novel_id)
.group_by(CostLog.model)
)
by_model = [
{"model": row[0], "calls": row[1], "cost_rmb": round(row[2] or 0, 6)}
for row in by_model_rows.all()
]
这让系统可以回答几个实际问题:
| 问题 | 数据来源 |
|---|---|
| 这部作品总共调用了多少次模型? | total_calls |
| 总 prompt / completion tokens 是多少? | total_prompt_tokens / total_completion_tokens |
| 哪个模型花得最多? | by_model |
| 哪类任务花得最多? | by_task |
| 平均每章成本多少? | avg_cost_per_chapter |
做 Agent 产品,一定要有这种成本意识。
尤其是自动写作、批量质检、批量修复这种功能,一次操作背后可能会触发很多模型调用。
没有成本统计,架构评审时就很难回答:
这个功能能不能默认开启?
这个模型路由策略是否划算?
一部 100 章小说跑完整流程大概多少钱?
质量审核是否值得用更强模型?
14. 错误协议:失败后要告诉前端怎么做
前面第 5 篇讲长任务体验时提过错误协议。
这里从 AI 底座角度再看一遍。
AI 业务错误不能只返回:
{ "detail": "error" }
因为前端需要知道下一步动作。
比如:
| 错误 | 前端应该做什么 |
|---|---|
| 上下文版本冲突 | 刷新并让用户确认 |
| AI 内容变更迁移 | 跳转到新工作台 |
| operation key 复用错误 | 停止重放 |
| 任务进行中 | 复用原 key 轮询 |
系统里定义了稳定错误协议:
OPERATION_ERROR_PROTOCOL_VERSION = "v1"
RetryClass = Literal["refresh_and_confirm", "do_not_retry", "navigate"]
_RETRY_CLASS_BY_CODE: dict[str, RetryClass] = {
"context_revision_conflict": "refresh_and_confirm",
"quality_source_stale": "refresh_and_confirm",
"ai_content_mutation_migrated": "navigate",
}
统一返回:
def operation_error_detail(
code: str,
message: str | None = None,
*,
details: dict[str, Any] | None = None,
refresh_scope: dict[str, int] | None = None,
asset_deep_link: str | None = None,
migration_url: str | None = None,
workspace_url: str | None = None,
) -> dict[str, Any]:
retry_class = _RETRY_CLASS_BY_CODE.get(code, "do_not_retry")
return {
"protocol_version": OPERATION_ERROR_PROTOCOL_VERSION,
"code": code,
"message": message or code,
"details": details or {},
"retry_class": retry_class,
"refresh_scope": refresh_scope if retry_class == "refresh_and_confirm" else None,
"asset_deep_link": asset_deep_link,
"migration_url": migration_url,
"workspace_url": workspace_url,
"operation_key_reuse_policy": "do_not_replay",
}
进行中的操作也有单独协议:
def operation_in_progress(
*,
kind: OperationKind,
identifier: int,
status: str,
poll_target: str,
include_legacy_receipt_id: bool = False,
) -> dict[str, Any]:
payload = {
"protocol_version": OPERATION_ERROR_PROTOCOL_VERSION,
"kind": kind,
"id": identifier,
"status": status,
"operation_key_reuse_policy": "reuse_original_key_for_polling",
"poll_target": poll_target,
}
if include_legacy_receipt_id:
payload["receipt_id"] = identifier
return payload
测试会验证 retry class 和 OpenAPI 契约:
@pytest.mark.parametrize(("code", "retry_class"), [
("context_revision_conflict", "refresh_and_confirm"),
("operation_key_reuse", "do_not_retry"),
("ai_content_mutation_migrated", "navigate"),
])
def test_operation_error_has_declared_retry_class(code, retry_class):
detail = operation_error_detail(
code,
refresh_scope={"novel_id": 7, "chapter_id": 11},
migration_url="/novel/7/workspace/11?panel=ai",
)
assert detail["protocol_version"] == OPERATION_ERROR_PROTOCOL_VERSION
assert detail["code"] == code
assert detail["retry_class"] == retry_class
assert detail["operation_key_reuse_policy"] == "do_not_replay"
这类协议听起来不如 Prompt 工程有吸引力。
但真实系统里,它决定了前端能不能把错误处理成用户能理解的动作。
15. 这和 Agent 有什么关系?
有人可能会问:
模型路由、限流、成本、错误协议,这些不是普通后端工程吗?
为什么放在 AI Agent 系列里讲?
我的理解是:
Agent 不是一次模型调用,而是一组会持续执行、会调用工具、会读写业务状态、会失败恢复的任务系统。
所以 Agent 对 AI 底座的要求比普通聊天功能更高。
普通聊天失败一次,用户可以重新问。
Agent 失败一次,可能已经:
创建了任务
冻结了上下文快照
生成了待审核提案
调用了多个工具
写入了部分步骤
触发了记忆提取
所以底座必须回答:
| 问题 | 为什么重要 |
|---|---|
| 选哪个模型 | 影响质量、速度和成本 |
| 怎么统一调用 | 影响系统可维护性 |
| 怎么处理空响应 | 影响任务状态可靠性 |
| 怎么处理 JSON | 影响结构化结果稳定性 |
| 怎么限流 | 影响账单和服务稳定性 |
| 怎么记录成本 | 影响产品是否可持续 |
| 怎么定义错误 | 影响用户下一步动作 |
| 怎么保护测试模式 | 影响安全边界 |
这就是为什么我说 AI 工程底座不是可选项。
它是 Agent 从 Demo 进入业务系统的地基。
16. 可以直接拿走的检查表
如果你也在做 Agent 系统,可以用下面这张表自查。
| 检查项 | 你需要确认的问题 |
|---|---|
| 模型配置是否集中 | provider、base url、api key、model 是否有统一配置入口? |
| 是否支持模型路由 | writing、quality、planning、creative 是否能使用不同模型? |
| 调用是否统一出口 | 业务层是否只调用 chat_text / chat_json / chat_json_with_tools? |
| JSON 解析是否统一 | Markdown 包裹、前后多余文本、解析失败是否有统一处理? |
| provider 差异是否隔离 | response_format 等差异是否被网关吸收? |
| 空响应是否兜底 | provider 返回空内容时是否有有限重试? |
| 错误是否可操作 | 前端是否知道该刷新、重试、跳转,还是停止? |
| AI 接口是否限流 | 是否对 AI-heavy endpoint 做后端限流? |
| 成本是否可追踪 | 是否记录作品、章节、模型、任务类型、token、费用? |
| 测试是否安全 | 测试模式是否强制只允许 loopback fake server? |
| 配置是否可热重载 | 改模型后是否重建 client,而不是继续用旧连接? |
这张表不是为了让系统变复杂。
恰恰相反,它是为了让复杂性有地方待着。
业务层应该专注创作流程:
上下文怎么构建
工具怎么拆边界
任务怎么执行
提案怎么审核
AI 底座负责处理:
模型怎么选
调用怎么发
失败怎么兜
成本怎么算
安全怎么守
边界清楚了,Agent 系统才不会越做越乱。
17. 总结
这一篇讲的是 AI Agent 的工程底座。
它不直接决定某一次生成效果好不好。
但它决定系统能不能长期稳定地跑。
我的结论是:
真正的 Agent 工程化,不是把模型调用包一层 SDK,而是把模型调用放进一套可路由、可观测、可限流、可计费、可测试、可恢复的业务底座里。
在当前 AI 小说创作系统里,这套底座至少包括:
配置预设
模型路由
统一 AI 网关
JSON 解析
Tool Calls 循环
测试模式安全边界
AI 接口限流
成本追踪
错误协议
这些东西看起来不够“AI”,但它们决定了 AI 能不能变成产品。
如果前几篇是在讲 Agent 怎么理解上下文、怎么调用工具、怎么跑长任务,那么这一篇讲的就是:
让 Agent 不靠运气跑起来。
下一篇我会继续拆前端驾驶舱:如何把复杂 Agent 流程做成用户能理解、敢操作、能持续使用的界面。