AI Agent 工程底座实战:模型路由、限流、错误处理和成本意识

本文是AI Agent 架构实战:从 Demo 到业务系统系列第 6 篇。前面几篇讲了上下文工程、工具边界、任务状态机和长任务体验,这一篇继续往底层走:当 Agent 真正开始跑任务之后,模型调用怎么稳定、怎么可控、怎么省钱、怎么兜底。

很多 Agent Demo 里,模型调用通常是一段很直接的代码:

python 复制代码
response = await client.chat.completions.create(...)

这当然能跑。

但真实业务系统里,问题很快就会变多:

text 复制代码
写正文、做质检、拆大纲、提取记忆,要不要用同一个模型?
模型返回空内容怎么办?
JSON 解析失败怎么办?
用户连续点 20 次 AI 按钮怎么办?
一次自动写作到底花了多少钱?
测试环境会不会误打到真实模型?
模型配置改了,服务要不要重启?

这些问题单看都不大。

但放到 AI Agent 系统里,它们会一起决定一件事:

Agent 到底是一个能长期跑的业务能力,还是一段靠运气工作的模型调用代码。

这一篇我继续结合当前的 AI 小说创作系统,拆一下我在项目里做的 AI 工程底座。

01. 先给结论

我现在不建议在业务代码里到处直接调用大模型。

更稳的做法是把模型调用收敛成一条链路:

层级 解决什么问题
配置层 provider、base url、默认模型、测试模式
路由层 不同任务类型选择不同模型
网关层 文本、JSON、工具调用统一出口
防护层 限流、超时、空响应重试、安全测试模式
观测层 日志、token 估算、成本记录
协议层 错误返回告诉前端下一步动作

对应到系统里,大概是这样:

text 复制代码
业务服务
  ↓
chat_text / chat_json / chat_json_with_tools
  ↓
get_model_for_task(task)
  ↓
AsyncOpenAI(OpenAI-compatible)
  ↓
日志 / 成本 / 错误协议 / 限流

这篇不是讲"怎么接入某个模型厂商"。

我更想讲的是:

当你要把 Agent 做成产品,模型调用周围必须长出哪些工程能力。

02. 为什么不能把模型调用散落在业务里

一开始做 AI 功能,很容易写成这样:

text 复制代码
质检接口里调一次模型
大纲接口里调一次模型
章节生成里调一次模型
人物生成里调一次模型
导出简介里调一次模型

每个地方都自己处理:

text 复制代码
model
temperature
max_tokens
timeout
JSON 解析
异常捕获
日志记录
成本统计

短期看很快。

长期看会出现几个问题。

问题 后果
模型选择分散 换模型要全项目搜索
JSON 解析分散 每个接口都有自己的脆弱兜底
异常格式分散 前端不知道失败后该刷新、重试还是跳转
成本不可见 自动写作跑多了之后不知道钱花在哪里
测试不安全 E2E 或本地测试可能误打真实模型

所以我在项目里做的第一件事,就是把模型调用收敛到统一网关。

业务层不关心具体 provider。

业务层只表达:

text 复制代码
我要做 writing
我要做 planning
我要做 quality
我要拿 JSON
我要允许工具调用

底座负责把这些意图翻译成具体模型调用。

03. 配置层:provider 不是散落的字符串

项目里所有 AI 配置都集中在 app/core/config.py

python 复制代码
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",
    },
}

配置对象只保留通用字段:

python 复制代码
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_baseai_model 可以被 .env 手动覆盖。

也就是说:

text 复制代码
默认:provider 决定 base url 和 model
高级:用户可以覆盖 base url 和 model

这对自托管产品很重要。

因为不同用户可能会接:

text 复制代码
DeepSeek
OpenAI
讯飞星辰
本地 OpenAI-compatible 服务
公司内部模型网关

如果 provider、base url、model 到处写死,后面一定会痛。

04. 热重载:模型配置改完不应该重启服务

自托管系统里,模型配置通常会在管理页面改。

如果用户每次改完都要重启后端,体验会很割裂。

所以系统提供了一个 reload_settings()

python 复制代码
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"]

管理接口保存配置后,会做两件事:

python 复制代码
@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

关键是保存后调用:

text 复制代码
reload_settings()
reset_ai_client()

因为 OpenAI SDK client 内部持有 base url、api key 和 http transport。

如果只改 settings,不重建 client,后续请求仍然可能走旧配置。

05. 模型路由:不是所有任务都该用同一个模型

Agent 系统里,不同任务对模型能力的要求不一样。

比如小说系统里:

任务 更看重什么
正文生成 速度、成本、稳定输出
质量审核 推理、结构化判断、发现问题
记忆提取 信息抽取准确性
创意发散 想象力、推理深度
大纲规划 长上下文理解和结构能力

如果全部用最强模型,成本会失控。

如果全部用最快模型,质检和规划可能不可靠。

所以配置里有一个任务到模型的映射:

python 复制代码
_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",
    },
}

真正给业务用的是一个小函数:

python 复制代码
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

业务代码不写死模型名。

它只传任务意图:

python 复制代码
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

文本调用是最基础的:

python 复制代码
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

这里统一处理了:

text 复制代码
client 获取
模型路由
messages 组装
temperature / max_tokens / timeout
日志 meta
异常包装
空内容重试
成本记录

业务层不再重复写这些东西。

它只要关心本次调用要解决什么问题。

07. 空响应重试:小兜底能省很多脏状态

模型调用不是每次都稳定。

有时 provider 会返回成功响应,但 content 是空。

如果业务层直接拿空内容继续走,后面可能会变成:

text 复制代码
空正文提案
空质检结果
JSON 解析失败
AgentStep 显示成功但结果不可用

所以网关里加了一个很小的兜底:

python 复制代码
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 返回内容为空")

测试也很直接:

python 复制代码
@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:

text 复制代码
质检结果
章节计划
场景拆解
记忆提取
工具调用最终答案

如果每个业务模块都自己解析 JSON,就会出现很多重复代码。

所以网关里有一个 extract_json_object()

python 复制代码
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() 之上增加结构化约束和解析。

python 复制代码
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 场景下,会额外传:

python 复制代码
response_format = {"type": "json_object"}

并在 system prompt 里补充:

text 复制代码
只返回合法 JSON 对象,不要 Markdown,不要额外说明

对应测试:

python 复制代码
@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()

python 复制代码
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]] = []

核心循环是:

python 复制代码
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 工具调用不是黑箱,它应该能被追踪:

text 复制代码
调用了哪个工具
传了什么参数
工具返回了什么
跑了几轮
最终回答是什么

这对排查 Agent 行为非常关键。

没有 trace,后面用户问"为什么 Agent 这么写",你只能猜。

10. 测试模式:不要让测试误打真实模型

这一点我觉得非常重要。

Agent 系统一旦进入 E2E 测试,很容易出现一个危险情况:

text 复制代码
测试本来应该打 fake OpenAI
配置不小心变成真实 API
CI 或本地测试开始调用真实模型

这不只是花钱问题。

还可能把测试数据发到外部服务。

所以系统里有一个 AI_TEST_MODE 安全边界。

python 复制代码
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 前强制校验:

python 复制代码
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,
            )

测试也明确表达了这个边界:

python 复制代码
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

这里还有一个小细节:

python 复制代码
trust_env=False
follow_redirects=False

测试模式下不继承代理环境,也不跟随重定向。

目的很简单:

测试模式必须 fail closed,而不是"尽量试试"。

11. 限流:不要等模型账单爆了才想起来防抖

AI 接口和普通 CRUD 接口不一样。

一次请求背后可能是:

text 复制代码
大上下文拼接
模型调用
工具循环
写入提案
触发后续记忆提取

所以限流不能只靠前端按钮 disabled。

后端需要一道最外层防线。

项目里用的是一个简单的 token-bucket middleware:

python 复制代码
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",
)

核心实现:

python 复制代码
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)

这个实现不复杂,但它解决了一个很现实的问题:

text 复制代码
用户误点
页面重复提交
脚本刷接口
前端 bug 导致循环请求

尤其是自托管产品,默认没有复杂账号体系。

那至少要有 IP 级别的 AI 接口限流。

12. 成本追踪:Agent 不能只关心效果,也要关心账单

Agent 系统的成本不是一次调用的成本。

而是一条任务链路的成本:

text 复制代码
上下文构建
章节规划
草稿生成
质量审核
自动修复
记忆提取

如果没有成本记录,你只能知道"今天花得有点多"。

但你不知道钱花在:

text 复制代码
哪个作品
哪个章节
哪个模型
哪类任务

所以系统里有 cost_logs 表:

python 复制代码
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 是估算的:

python 复制代码
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)

记录一次调用:

python 复制代码
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() 里会自动接上成本记录:

python 复制代码
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:

python 复制代码
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)

作品成本汇总会按模型和任务类型分组:

python 复制代码
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 产品,一定要有这种成本意识。

尤其是自动写作、批量质检、批量修复这种功能,一次操作背后可能会触发很多模型调用。

没有成本统计,架构评审时就很难回答:

text 复制代码
这个功能能不能默认开启?
这个模型路由策略是否划算?
一部 100 章小说跑完整流程大概多少钱?
质量审核是否值得用更强模型?

14. 错误协议:失败后要告诉前端怎么做

前面第 5 篇讲长任务体验时提过错误协议。

这里从 AI 底座角度再看一遍。

AI 业务错误不能只返回:

json 复制代码
{ "detail": "error" }

因为前端需要知道下一步动作。

比如:

错误 前端应该做什么
上下文版本冲突 刷新并让用户确认
AI 内容变更迁移 跳转到新工作台
operation key 复用错误 停止重放
任务进行中 复用原 key 轮询

系统里定义了稳定错误协议:

python 复制代码
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",
}

统一返回:

python 复制代码
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",
    }

进行中的操作也有单独协议:

python 复制代码
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 契约:

python 复制代码
@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 有什么关系?

有人可能会问:

text 复制代码
模型路由、限流、成本、错误协议,这些不是普通后端工程吗?
为什么放在 AI Agent 系列里讲?

我的理解是:

Agent 不是一次模型调用,而是一组会持续执行、会调用工具、会读写业务状态、会失败恢复的任务系统。

所以 Agent 对 AI 底座的要求比普通聊天功能更高。

普通聊天失败一次,用户可以重新问。

Agent 失败一次,可能已经:

text 复制代码
创建了任务
冻结了上下文快照
生成了待审核提案
调用了多个工具
写入了部分步骤
触发了记忆提取

所以底座必须回答:

问题 为什么重要
选哪个模型 影响质量、速度和成本
怎么统一调用 影响系统可维护性
怎么处理空响应 影响任务状态可靠性
怎么处理 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,而不是继续用旧连接?

这张表不是为了让系统变复杂。

恰恰相反,它是为了让复杂性有地方待着。

业务层应该专注创作流程:

text 复制代码
上下文怎么构建
工具怎么拆边界
任务怎么执行
提案怎么审核

AI 底座负责处理:

text 复制代码
模型怎么选
调用怎么发
失败怎么兜
成本怎么算
安全怎么守

边界清楚了,Agent 系统才不会越做越乱。

17. 总结

这一篇讲的是 AI Agent 的工程底座。

它不直接决定某一次生成效果好不好。

但它决定系统能不能长期稳定地跑。

我的结论是:

真正的 Agent 工程化,不是把模型调用包一层 SDK,而是把模型调用放进一套可路由、可观测、可限流、可计费、可测试、可恢复的业务底座里。

在当前 AI 小说创作系统里,这套底座至少包括:

text 复制代码
配置预设
模型路由
统一 AI 网关
JSON 解析
Tool Calls 循环
测试模式安全边界
AI 接口限流
成本追踪
错误协议

这些东西看起来不够"AI",但它们决定了 AI 能不能变成产品。

如果前几篇是在讲 Agent 怎么理解上下文、怎么调用工具、怎么跑长任务,那么这一篇讲的就是:

让 Agent 不靠运气跑起来。

下一篇我会继续拆前端驾驶舱:如何把复杂 Agent 流程做成用户能理解、敢操作、能持续使用的界面。

相关推荐
孤狼GPT1 小时前
从聊天工具到开发系统:ChatGPT、Codex、Plus与Pro正在重新分工
chatgpt·ai编程·codex·chatgpt plus·chatgpt pro
cooldream20092 小时前
AI 编程系列之 11:AI Coding 工程师的能力模型——5 年后的护城河
ai编程·vibe coding·claude code
烬羽3 小时前
AI 写代码总翻车?试试"先画图再砌墙"的 Vibe Coding 三步法
react.js·ai编程·vibecoding
太平洋月光3 小时前
AI 快捷指令:Cursor Rules · Commands · Skills
前端·ai编程
唐老板3 小时前
AI 编程的保密底线:企业代码不能这么漏
ai编程
武子康3 小时前
Pi vs Claude Code vs Codex 正确读法:6 组同模型匹配 + 2.08×/1.46×/1.20×/1.54×/1.22×/1.44× 成
人工智能·ai编程·claude
太平洋月光3 小时前
stagewise如何结合cursor开发
前端·ai编程
码哥字节3 小时前
Superpowers 6.0 的 SDD 重写,我扒了源码才知道:token 砍半不是优化,是设计哲学的转向
ai编程·claude
程序员在囧途3 小时前
AIGC SaaS 多租户平台怎么做行业客户二次演示?从 https://aigc.likeadmin.cn 到开源应用中心复盘链路
开源·aigc