加载中...
  • 做 Agent 开发前,哪些大模型机制必须先懂 loading

    吃透 AI Agent 开发 · 第 04 篇 · 第一章 · 认知校准

    不需要先读完论文,但要理解 token、上下文窗口、reasoning、tool call 和 provider 差异,否则很多工程问题会被误判成 Prompt 问题。

    不需要先读完论文,但要理解 token、上下文窗口、reasoning、tool call 和 provider 差异,否则很多工程问题会被误判成 Prompt 问题。

    模型不是一个会聊天的函数

    调用模型时,输入并不是一段字符串,输出也不是一个字符串。消息有角色、part、工具调用、reasoning、用量和完成原因;上下文窗口有限,输出预算和输入预算还会互相影响。Agent 工程的很多“玄学”,其实是协议被简化后留下的错觉。

    用一次请求观察五个量

    sequenceDiagram
      participant App as 应用
      participant P as Provider
      participant M as 模型
      App->>P: messages + tools + options
      P->>M: tokenized request
      M-->>P: text/reasoning/tool parts
      P-->>App: stream + usage + finish reason
      App->>App: 保存可回传的 assistant parts
    

    至少记录输入 token、输出 token、首 token 时间、完成原因和是否产生工具调用。没有这些字段,就无法解释“为什么变贵”“为什么重试后变差”或“为什么工具结果没有进入下一轮”。

    Token 和窗口的工程意义

    Token 不是字数换算器。同一份中文、代码和 JSON 的 token 密度不同,字符串长度相近的两个请求可能消耗完全不同的预算。上下文窗口也不是“能放多少历史”的静态箱子,system prompt、工具 schema、图片和 reasoning 都可能占用它。

    interface RequestBudget {
      inputTokens: number
      reservedOutputTokens: number
      toolSchemaTokens: number
      remaining: number
    }
    
    function canInject(budget: RequestBudget, extra: number): boolean {
      return budget.inputTokens + budget.toolSchemaTokens + extra + budget.reservedOutputTokens <= budget.remaining
    }
    

    预算判断应该发生在注入上下文之前。等模型请求失败后再压缩,通常已经太晚,而且压缩结果会丢掉原始证据。

    Reasoning 要保留,但不必原样展示

    reasoning 是模型协议的一部分,不等于用户想看的解释。应用可以把它保存在后续请求和审计中,同时只向界面展示进度或简短状态。把 reasoning 当普通文本拼进最终回答,可能泄露内部提示,也会让重试时的消息格式失真。

    Provider 差异不只是模型名

    不同 provider 对 tool choice、reasoning effort、finish reason 和错误字段的命名可能不同。适配层的目标不是抹平所有差异,而是明确哪些差异会影响 Loop、工具和计费。最危险的做法是把一个 provider 的私有字段直接写进通用 transcript。

    q-code 的四个落点

    阅读顺序 路径 要回答的问题
    1. reasoning 配置 src/runtime/reasoning-config.ts 推理强度如何成为统一配置
    2. DeepSeek 兼容 src/runtime/deepseek-compat.ts 私有请求体如何被隔离
    3. Loop 消息 src/agent/loop.ts assistant parts 怎样回传
    4. 用量统计 src/usage/tracker.ts token 和成本如何归一化

    失败反例:把 reasoning 当普通文本展示

    把 reasoning 当普通文本展示,或把历史模型的私有字段直接复制到另一个 provider。结果可能是界面把内部过程泄露给用户,下一轮请求又因为 part 顺序不合法而失败,最后团队误以为是 Prompt 质量下降。

    修复要建立内部消息协议,并在 provider 适配边界做转换。任何不能安全回传的字段都应有明确的降级策略:保留在审计、转为摘要,或在不影响下一轮的情况下丢弃。

    做一次协议观测实验

    练习:记录一轮普通文本、一轮 reasoning、一轮工具调用和一次超时请求的完整事件。标出哪些字段进入下一轮、哪些字段只进审计、哪些字段只进 UI。换一个 provider 重跑,找出需要适配而不应该污染业务代码的差异。

    把 assistant 消息拆开,别急着转成字符串

    一次工具调用轮次里,assistant 可能同时包含 reasoning、说明文本和多个 tool-call part。应用若只保存 response.text,下一轮模型就看不到自己刚才发出的调用;若只保存工具名和参数,又可能丢掉 provider 用来关联结果的 callId。

    下面是一份简化后的内部消息账本:

    [
      {"role":"user","parts":[{"type":"text","text":"读取配置并解释超时"}]},
      {"role":"assistant","parts":[
        {"type":"reasoning","provider":"deepseek-compatible","opaque":"..."},
        {"type":"text","text":"我先读取配置。"},
        {"type":"tool-call","callId":"call_7","name":"read_file","input":{"path":"config.json"}}
      ]},
      {"role":"tool","parts":[
        {"type":"tool-result","callId":"call_7","name":"read_file","status":"ok","output":{"timeoutMs":10000}}
      ]}
    ]
    

    内部协议不必和任一供应商完全相同,但要保留下一轮所需的关联信息。发送前由 adapter 映射为供应商格式,接收后再映射回来。这样业务层处理的是 tool-calltool-result,而不是某个 SDK 的私有类。

    opaque reasoning 尤其需要谨慎。有的供应商要求把原始 reasoning part 原样回传,有的只接受签名字段,有的根本不暴露。通用层可以保留不透明载荷和来源,却不应该尝试“理解后重写”它。跨 provider 恢复会话时,无法兼容的 part 要有明确策略,例如转成一条已验证事实摘要,而不是直接复制未知字段。

    Finish Reason 是控制信号,不是装饰字段

    stoptool-callslengthcontent-filtererror 可能都伴随一些文本,但下一步完全不同。遇到 length 时把半截 JSON 当最终回答,会造成解析错误;遇到内容过滤时自动重试相同输入,只会重复失败;工具调用完成原因则要求先执行工具而不是展示为最终文本。

    switch (result.finishReason) {
      case 'tool-calls':
        return executeDeclaredCalls(result.message)
      case 'stop':
        return finishWithText(result.text)
      case 'length':
        return recoverTruncatedTurn(result)
      case 'content-filter':
        return failWithPolicyNotice(result)
      default:
        return failWithProviderContext(result)
    }
    

    恢复 length 也不能无脑说“继续”。若截断发生在自然语言中,可以要求从最后稳定段落继续;若发生在工具参数 JSON 中,必须放弃残缺调用并让模型重新生成完整参数。真实工具只接受解析成功、schema 合法的输入。

    上下文窗口要预留输出,不是塞满再说

    假设模型窗口为 128K token,当前历史 92K,工具 schema 18K,系统规则 8K。表面看还剩 10K,但如果任务需要生成 12K 的代码补丁,请求在开始前就已经没有足够空间。

    预算可以按保守值拆:

    窗口上限             128K
    - 稳定 system          8K
    - tools schema         18K
    - 历史与当前输入       92K
    - 输出预留             12K
    = 超预算                2K
    

    此时有三个选择:压缩历史、减少本轮工具集合、把长材料 offload 后只注入索引。最差选择是降低输出上限到 2K 后继续,因为模型可能在关键代码中途停止,让上层误以为任务已经完成。

    工具 schema 往往是隐藏大户。注册 150 个工具并不等于模型拥有更强能力,可能只是让每轮多消耗一大段固定 token。JIT 工具搜索的价值之一,就是把“全部能力目录”与“本轮详细 schema”分开。

    Streaming 里首 token 不是第一段正文

    有 reasoning 的模型可能先产生推理事件,几十秒后才出现文本;有的 provider 在首个网络 chunk 里只给 role 或 usage 元数据。TTFT 如果定义不清,两个模型的延迟数据无法比较。

    建议同时记录三个时刻:首个响应字节、首个有效 part、首个用户可见文本。对工具型任务,首个有效 part 可能是 tool-call,此时界面应显示“准备读取文件”,而不是一直停在“模型思考中”。这既改善体验,也让排障知道延迟发生在网络、推理还是文本生成阶段。

    流式事件必须能合并成和非流式响应等价的完整消息。测试可以把同一 fixture 切成不同 chunk 边界,最后都得到相同 parts。若 chunk 切法会改变工具参数,解析器就还不可靠。

    重试时分清“请求没到”和“结果没回来”

    模型请求在建立连接前失败,通常可以安全重试;工具调用已经由模型生成且写操作可能执行后,重新跑整轮可能重复副作用。Agent Loop 需要记录本轮已经确认的工具结果,重试模型时复用消息账本,而不是从用户输入重新开始。

    429 和 503 可以按 Retry-After 与指数退避处理,401 通常不该重试,内容过长需要先改请求。把所有错误包成 ModelError: try again,既浪费配额,也掩盖配置问题。

    图片和文件也会进入同一预算

    多模态请求不是把路径字符串发给模型。应用要读取图片、校验类型和大小,再构造 provider 支持的 image part。图片正文只应进入本轮请求,transcript 可保存“用户附加了一张 1024×768 PNG”的摘要,审计记录 hash 和大小,不应默认保存 base64。

    这个边界与文本文件一样:模型只能看到真正被构造成 part 的内容。用户在 UI 里选中了文件,不代表 provider 请求一定携带成功。对多模态链路做测试时,要同时断言 request parts、持久化摘要和审计脱敏,而不是只看界面出现了缩略图。

    理解这些协议细节后,很多 Prompt 问题会恢复成普通工程问题:消息 part 丢了、finish reason 分支错了、窗口预算算少了、重试跨过副作用边界。它们都可以用记录、状态和测试解决,不必靠反复改一句提示词碰运气。

    Temperature、Seed 和“可复现”之间的距离

    降低 temperature 可以减少输出分散,某些 provider 支持 seed,也不代表同一请求永远得到相同工具轨迹。模型后端版本、并行计算、工具结果时间、动态上下文和安全策略都可能变化。

    对协议测试使用 FakeModel,不让随机性进入;对真实决策 Eval 保存模型标识、Prompt hash、toolsetId、上下文 manifest 与日期,重复运行看通过率分布。Seed 只作为运行参数记录,不作为确定性承诺。

    需要结构化输出时,schema/validator 比低 temperature 更可靠。模型仍可能生成语义矛盾字段,领域检查继续生效。需要创意候选时可以提高采样,再让确定性工具验证事实;高风险执行不因为“温度为 0”放松权限。

    这也解释了为什么线上事故难以靠“我本地重跑没复现”结案。先比较请求 manifest、provider capability、消息 parts 和工具状态,确认系统输入是否相同,再讨论模型波动。

    本文目录
    本文目录