跳到主要内容

LLM 客户端:单结构化工具调用与野模型容错

30 秒导读: page-agent 要在浏览器里驱动任意一个 OpenAI 兼容模型——从 GPT-5 到 Qwen、DeepSeek、Kimi、本地小模型。这些模型对"函数调用"的支持参差不齐:有的不认 tool_choice,有的把参数字符串化两遍,有的干脆把 JSON 塞进 content。本章讲 page-agent 怎么用一个宏工具(MacroTool)+ 一层请求补丁 + 一层响应修复,把这堆"野模型"统一成"每步吐出一个结构化动作"的可靠管道。

本章聚焦"模型这一环":动作怎么打包成单次工具调用、请求怎么按模型定制、响应错了怎么掰正。它不讲外层的 ReAct 循环编排(见 01-agent-loop),也不讲每个动作在 DOM 上具体做了什么(见 03-tools-and-actions)。


1. 这是什么(零基础也能懂)

一句话定义

page-agent 的 LLM 层做一件事:每一步,让模型只输出一个结构化对象——先是几句反思,然后是一个动作——并保证不管模型多不规范,拿到的东西都能被程序可靠地解析和执行。

它要解决的真实困境

假设你写了个网页 agent,想让它支持用户"自带模型"(BYOK)。用户填个 baseURLmodel 就能跑。问题来了:

  • 用户填的可能是 gpt-5.2,也可能是 qwen-maxdeepseek-chatclaude-opus、甚至某个本地 Ollama 小模型。
  • OpenAI 的 /chat/completions 有"函数调用"标准,但每家实现都有脾气:
    • Qwen 默认开思考链、要显式关;
    • DeepSeek 不接受 tool_choice 字段;
    • Claude 的 tool_choice 格式和 OpenAI 不一样;
    • 小模型经常不调用工具,而是把答案当普通文本写在 content 里;
    • 有的模型把工具参数 JSON 字符串化两次,或多包一层 {name, arguments}

如果每接一个模型就写一套 if-else,代码会烂掉。page-agent 的答案是:把差异集中到两个可控的点——发请求前的 modelPatch,收响应后的 normalizeResponse——中间的核心逻辑对所有模型一视同仁。

一句话直觉

把它想成一个"翻译 + 纠错"的中间层。出去时按模型方言改口音(modelPatch);回来时把对方结巴、说错语序的话,硬掰回一句标准的"我先想了什么,然后要做这个动作"(normalizeResponse)。中间的业务代码永远只听标准普通话。


2. 顶层全景(它大概怎么转)

一步 agent loop 里,"问模型"这段的数据流如下。怎么读这张图:从上到下是一次 invoke 的生命周期,左侧标了负责的文件。

┌───────────────────────────────────────────┐
core: 把 N 个工具 │ PageAgentCore.#packMacroTool() │
压成 1 个宏工具 ───▶ │ { AgentOutput: {反思字段 + action:union} } │
└───────────────────┬───────────────────────┘
│ messages + { AgentOutput }

┌───────────────────────────────────────────┐
llms: 带重试的入口 ──▶ │ LLM.invoke() → withRetry(...) │
└───────────────────┬───────────────────────┘

┌────────────────────────────────────────────────────────────┐
│ OpenAIClient.invoke() │
│ ① 组装请求: tool_choice / parallel_tool_calls:false │
│ ② modelPatch(body, baseURL) ← 按模型改方言 │
│ ③ transformRequestBody(body) ← 用户最后的手动挡 │
│ ④ fetch → HTTP 错误分类 (AUTH/RATE_LIMIT/SERVER...) │
│ ⑤ finish_reason 分类 (length→CONTEXT_LENGTH ...) │
│ ⑥ normalizeResponse(data) ← autoFixer 掰正畸形响应 │
│ ⑦ 取 tool_call → JSON.parse → 宏 schema 校验 │
│ ⑧ tool.execute(input) ← 真正跑那个动作 │
└───────────────────┬────────────────────────────────────────┘
│ 抛出的任何 InvokeError

retryable? ── 是 ──▶ 回到 withRetry 再试(至多 maxRetries)
└─ 否 ──▶ 直接抛出(AUTH/CONTEXT_LENGTH/CONTENT_FILTER)

各部件一句话职责:

部件干什么文件
#packMacroTool把 core 里所有动作工具用 zod union 压成一个 AgentOutput 宏工具packages/core/src/PageAgentCore.ts:386
LLM / withRetry带重试事件的薄封装,只对"可重试错误"重试packages/llms/src/index.ts:19,58
OpenAIClient.invoke请求组装、HTTP/finish_reason 错误分类、解析、执行工具packages/llms/src/OpenAIClient.ts:29
modelPatch按模型名/厂商改请求体(关思考、改 tool_choice 格式等)packages/llms/src/utils.ts:39
normalizeResponse把各种畸形响应掰回标准 AgentOutput 形状packages/core/src/utils/autoFixer.ts:21
InvokeError / InvokeErrorTypes统一的错误分类体系,自带 retryable 标记packages/llms/src/errors.ts

一个关键分工要先说清楚:OpenAIClient通用的——它只知道"给我一堆工具,我让模型选一个、解析参数、执行它"。它并不知道什么叫"动作"或"反思"。"把所有动作压成一个宏工具"这件事发生在 core 层(#packMacroTool),client 层收到的永远只是一个名叫 AgentOutput 的普通工具。这层解耦让 llms 包可以独立于 page-agent 复用。


3. 核心机制一:把所有动作压成一个宏工具

它要解决的小问题

标准的"函数调用"是:你给模型 N 个工具,模型自己挑一个调。但这有两个麻烦:

  1. 模型会乱挑、会并行挑多个,行为不可预测;野模型对"多工具选择"支持更差。
  2. page-agent 想强制一个心智模型:每步都得先反思(上一步做得怎样?记住了什么?下一步目标?)再动作——但普通函数调用没地方塞这些反思。

思路:合并成一个,再逼模型填两段

page-agent 不给模型 N 个工具,而是给一个工具 AgentOutput,它的入参 schema 长这样(白话版):

// 示意,非源码
{
"evaluation_previous_goal": "上一步点了登录,页面已跳到表单", // 反思:回看
"memory": "用户名已填,还差密码和提交", // 反思:记忆流
"next_goal": "填密码字段", // 反思:前瞻
"action": { // 动作:只能挑一个
"input_text": { "index": 12, "text": "hunter2" }
}
}

前三个字段是反思三件套(AgentReflection),动作 action 是一个"从所有工具里选一个"的联合类型。这样一次工具调用就同时拿到了"想法 + 动作",天然实现了 ReAct 的"reflection-before-action"。反思字段的语义定义见 packages/core/src/types.ts:171(AgentReflection),它们都是 Partial(可选)——模型漏填也不致命。

真实实现:zod union 是关键

#packMacroTool 把每个动作工具包成 { 工具名: 该工具的入参 schema },再用 z.union 合起来当 action 的类型:

// PageAgentCore.ts:389 —— 每个工具包成 { toolName: schema },再 union
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
return z.object({ [toolName]: tool.inputSchema }).describe(tool.description)
})
const actionSchema = z.union(actionSchemas as unknown as [z.ZodType, z.ZodType, ...z.ZodType[]])

const macroToolSchema = z.object({
evaluation_previous_goal: z.string().optional(),
memory: z.string().optional(),
next_goal: z.string().optional(),
action: actionSchema,
})

这个 zod schema 最终会被 zodToOpenAITool(packages/llms/src/utils.ts:15)通过 z.toJSONSchema(..., { target: 'openapi-3.0' }) 转成 OpenAI 的 function.parameters,发给模型。也就是说,动作的"菜单"是以 JSON Schema 的 oneOf 形式呈现给模型的

宏工具在 loop 里以 { AgentOutput: ... } 的名字注册,并强制模型必须调它(PageAgentCore.ts:278,285):

const macroTool = { AgentOutput: this.#packMacroTool() }
const result = await this.#llm.invoke(messages, macroTool, signal, {
toolChoiceName: 'AgentOutput', // 逼模型只调这个
normalizeResponse: (res) => normalizeResponse(res, this.tools),
})

宏工具的 execute 做什么:分发 + 计时 + 数等待

宏工具的 execute 回调(PageAgentCore.ts:406)是真正"落地"的地方。它从 action 里取出唯一的键当工具名,查表拿到真工具,执行,并顺手做三件事:

  1. 拼反思文本:把非空的三段反思拼成带 emoji 的日志(✅ / 💾 / 🎯,PageAgentCore.ts:417-423)。
  2. 发活动事件:executing / executed,供 UI 展示(PageAgentCore.ts:436,448)。
  3. 累计等待时间——这是个精妙的小设计:
// PageAgentCore.ts:456 —— 只有 wait 动作累加,其它动作清零
if (toolName === 'wait') {
this.#states.totalWaitTime += toolInput?.seconds || 0
} else {
this.#states.totalWaitTime = 0
}

为什么要数?因为野模型容易"卡住"——反复选择 wait 空耗。累计等待超过阈值时,下一轮会往上下文里推一条观察:"你已经累计等了 N 秒,没好理由别再等了"(PageAgentCore.ts:540-544)。用一句提示词把模型从死循环里拽出来,而不是靠硬编码的循环控制。

关键点:宏工具的 execute 里所有等待都还会再查一次中止信号(signal.throwIfAborted(),PageAgentCore.ts:442),即使工具自己忽略了 signal 也能被强制打断。


4. 核心机制二:OpenAIClient.invoke 的请求组装与错误分类

OpenAIClient.invoke(packages/llms/src/OpenAIClient.ts:29)是唯一真正碰 HTTP 的地方。它的价值不在"调 API"(那几行很平淡),而在把一次调用可能出的岔子分门别类,让上层能决定"该重试还是该放弃"。

请求组装:三个决定成败的字段

// OpenAIClient.ts:42 —— tool_choice 的两档
let toolChoice: unknown = 'required'
if (options?.toolChoiceName && !this.config.disableNamedToolChoice) {
toolChoice = { type: 'function', function: { name: options.toolChoiceName } }
}
字段为什么
tool_choicetoolChoiceName → 指名调 AgentOutput;否则 'required'逼模型必须调工具;disableNamedToolChoice 兜底那些不认对象格式 tool_choice 的模型
parallel_tool_callsfalse每步只要一个动作,禁止并行工具调用(OpenAIClient.ts:51)
temperature仅当用户显式设过才发很多新模型一带 temperature 就报错;默认不发(OpenAIClient.ts:55)

组装完请求体后,是两道加工:

  1. modelPatch(requestBody, this.config.baseURL)(OpenAIClient.ts:59)——按模型改方言(见 §5)。
  2. this.config.transformRequestBody(requestBody)(OpenAIClient.ts:63)——用户的"手动挡",最后一次任意改写请求体的机会;它自己抛错会被包成 CONFIG_ERROR(不可重试)。

错误分类体系:两次分诊

第一次分诊按 HTTP 状态码(OpenAIClient.ts:92-127):

状态码错误类型可重试?
401 / 403AUTH_ERROR否(密钥错了,重试无用)
429RATE_LIMIT
≥ 500SERVER_ERROR
其它 4xxUNKNOWN

第二次分诊按 finish_reason(OpenAIClient.ts:148-174)——即使 HTTP 200,模型也可能没好好结束:

switch (choice.finish_reason) {
case 'tool_calls':
case 'function_call': // gemini 的叫法
case 'stop': // 有些模型即使调了工具也回 stop
break // 这三种都算正常
case 'length': throw ... CONTEXT_LENGTH // 被 max_tokens 截断
case 'content_filter': throw ... CONTENT_FILTER // 被安全系统拦
default: throw ... INVALID_SCHEMA
}

注意两处兼容野模型的细节:function_call 是 Gemini 的方言,stop 是"有些模型调了工具却仍报 stop"——这里不把它们当错误,是硬泡出来的经验。

解析与执行:normalizeResponse 在最前面

拿到 200 且 finish_reason 正常后,顺序是:

  1. 先修复:options?.normalizeResponse(data)(OpenAIClient.ts:177)——把畸形响应掰正(见 §6)。修复在取 tool_call 之前,所以下面所有解析都基于修好的数据。
  2. tool_calls[0].function.name,没有就抛 NO_TOOL_CALL(OpenAIClient.ts:182,可重试)。
  3. JSON.parse(arguments),失败抛 INVALID_TOOL_ARGS(OpenAIClient.ts:214)。
  4. tool.inputSchema.safeParse(...)宏工具的 zod schema做二次校验(OpenAIClient.ts:225)。
  5. tool.execute(toolInput)(OpenAIClient.ts:240)——这一步才真正触发 §3 里那个分发逻辑。工具自己抛错包成 TOOL_EXECUTION_ERROR

有意思的是:校验发生了两层normalizeResponse 内部已经用各工具的 schema 校验过一次动作(见 §6 的 validateAction),这里 client 又用宏 schema 整体校验一次。前者给出可读的错误信息并做强制类型转换,后者是最终防线。


5. 核心机制三:modelPatch —— 把请求改成每个模型的方言

modelPatch(packages/llms/src/utils.ts:39)是"出去时改口音"的那一层。它的目标(源码注释原话):调和各模型接受的参数差异、尽量关掉思考链、压低返回 token、给已知小模型抬温度以提高自愈概率。

先归一化模型名

同一个模型在不同厂商下 ID 五花八门:gpt-5.2gpt-5_2-dateopenai/gpt-5.2-chat……normalizeModelName(utils.ts:208)统一成小写、去 / 前缀、去 _.,把它们都归一成 gpt52。之后所有匹配都基于归一化后的名字。

按模型/厂商打补丁(节选)

模型前缀补丁做了什么源码
qwenenable_thinking=false;非 max/plus 的抬温度到 1.0utils.ts:47
deepseek关思考,并删掉 tool_choice(DeepSeek 不认)utils.ts:56
gpt按 5.1/5.2、5、chat-latest 等细分设 reasoning_effort/verbosityutils.ts:62
claude关 thinking;把 tool_choice 从 OpenAI 格式转成 Claude 格式(required{type:'any'})utils.ts:88
geminireasoning_effort,2.5 非 Pro 直接 noneutils.ts:116
minimaxparallel_tool_calls;仅 M3 能关思考utils.ts:161

厂商层还有一层:如果 baseURLopenrouter.ai(getProvider,utils.ts:225),OpenRouter 用 reasoning 对象而非 reasoning_effort,于是把前面各补丁设的 reasoning_effort/thinking 翻译成 { reasoning: { enabled, effort } }(utils.ts:174-188)。

Claude 的转换值得单看,因为它体现了"同一意图、不同格式"的翻译:

// utils.ts:95 —— OpenAI 的 tool_choice 语义 → Claude 的等价格式
if (body.tool_choice === 'required') {
body.tool_choice = { type: 'any' } // 必须调某个工具
} else if (body.tool_choice?.function?.name) {
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name } // 指名
}

一个诚实的边界:注释里明说这套补丁是"只 patch 已知模型",且 @todo 承认本地/第三方托管的同名模型 schema 可能不同(utils.ts:36)。所以 modelPatch尽力而为的启发式,不是万能保证——这也是为什么还要有下面的响应修复兜底。


6. 核心机制四:autoFixer —— 把畸形响应掰回标准形状

modelPatch 管出去,normalizeResponse(packages/core/src/utils/autoFixer.ts:21)管回来。它是整章最能体现"兼容野模型"含金量的一段:把模型五花八门的"没按格式说话",硬掰回标准的 AgentOutput 结构。

它要对付的畸形形态

源码顶部的注释列了它见过的"病症"(autoFixer.ts:9-19)。归纳成一张表:

病症模型实际吐了什么修复档
没调工具,JSON 写在 content 里message.content = "{...}"#2/#3/#4
把动作名当工具名调tool_calls[0].name = "click_element_by_index" 而非 AgentOutput#1
参数被字符串化两遍arguments = "\"{\\\"action\\\":...}\""safeJsonParse 二次解析
content 里多包了一层 {name, arguments}{type:'function'}嵌套 wrapper#2 / #3
只给了动作、缺反思外壳{"click_element_by_index": {...}}#4
单字段工具给了原语{"click_element_by_index": 2}validateAction 强转
啥像样动作都没有空 / 无法识别#5 兜底成 wait

修复流水线:一层层剥

怎么读:从"参数在哪"开始,逐档把外壳剥掉,直到露出 { 反思..., action }

tool_calls[0].arguments 存在?
├─ 是 ──▶ safeJsonParse
│ └─ name 不是 AgentOutput? → #1: 包成 { action: 解析结果 }
└─ 否 ──▶ 看 message.content
├─ 能抽出 JSON?
│ ├─ name === 'AgentOutput' → #2: 解包 arguments
│ ├─ type === 'function' → #3: 解包 function.arguments
│ └─ 没有任何反思/action 字段? → #4: 包成 { action: ... }
└─ 抽不出 → 抛错(彻底没救,交给重试)

── 通用后处理 ──
safeJsonParse(整体) // 双重字符串化:再解一次
若 action 是字符串 → 再 parse // action 也可能被单独字符串化
validateAction(action) // 按各工具 schema 校验 + 单字段原语强转
若还是没 action → #5: action = { wait: { seconds: 1 } } // 缺动作兜底

对应的关键源码(autoFixer.ts:34-97,节选两处):

// #1: 模型只回了动作层(把动作名当成工具名)
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
}
// ...
// #5: 什么动作都凑不出来 → 兜底成"等 1 秒",而不是崩
if (!resolvedArguments.action) {
resolvedArguments.action = { wait: { seconds: 1 } }
}

最后它把修好的东西重新塞回标准形状(autoFixer.ts:100):强制 tool_calls[0].function.name = 'AgentOutput',arguments = JSON.stringify(resolvedArguments)。于是 §4 里 client 后续的解析代码,拿到的永远是一个规规矩矩的 AgentOutput 调用。

validateAction:友好报错 + 单字段强转

validateAction(autoFixer.ts:130)有两个作用:

  1. 给出人能读的错误:直接查 tools Map 拿对应工具的 schema 单独校验,而不是让 union schema 吐一坨没法看的联合类型错误。未知动作会列出所有可用动作名(autoFixer.ts:138)。
  2. 单字段工具的原语强转——这是个体贴的容错:
// autoFixer.ts:149 —— {"click_element_by_index": 2} → {"...": {"index": 2}}
if (schema instanceof z.ZodObject && value !== null && typeof value !== 'object') {
const requiredKey = Object.keys(schema.shape).find(
(k) => !schema.shape[k].safeParse(undefined).success // 找那个必填字段
)
if (requiredKey) value = { [requiredKey]: value }
}

模型偷懒直接给了个数字 2,而工具只有一个必填字段 index,那就自动补成 { index: 2 }用 schema 自身的信息推断"这个原语该塞进哪个字段",而不是硬编码每个工具。


7. 核心机制五:withRetry —— 只对该重试的错重试

修复也救不回来时(比如模型连着几次都不调工具),就靠重试。LLM.invoke(packages/llms/src/index.ts:36)把 client.invoke 包进 withRetry

LLM 本身继承 EventTarget,每次重试会派发一个 retry 事件(带 attempt / maxAttempts / lastError),供 UI 显示"重试中"(index.ts:44)。

withRetry(index.ts:58)的判断逻辑很克制:

// index.ts:70 —— 什么该重试、什么不该
if ((error as any)?.name === 'AbortError') throw error // 用户中止:立刻停
if (error instanceof InvokeError && !error.retryable) throw error // 不可重试类:直接抛
attempt++
if (attempt > settings.maxRetries) throw error // 超次数:放弃
await new Promise((resolve) => setTimeout(resolve, 100)) // 固定 100ms 后再试

"可不可重试"不在这里 if-else,而是编码进了错误类型本身InvokeError 构造时就按 RETRYABLE_TYPES 名单算好 retryable 布尔(packages/llms/src/errors.ts:52):

可重试(瞬态/模型抽风)不可重试(重试也没用)
NETWORK_ERRORRATE_LIMITSERVER_ERRORCONFIG_ERROR(本地配置错)
NO_TOOL_CALLINVALID_TOOL_ARGSINVALID_RESPONSEINVALID_SCHEMAAUTH_ERROR(密钥错)
TOOL_EXECUTION_ERRORUNKNOWNCONTEXT_LENGTH(prompt 太长)、CONTENT_FILTER(被拦)

设计上很清晰:"模型这次没说对"是可重试的(下次可能对),"配置/额度/长度这类结构性问题"不可重试(重试只是浪费钱)。默认 maxRetries 为 2(parseLLMConfig,index.ts:104)。

parseLLMConfig:填默认值的地方

parseLLMConfig(index.ts:83)把用户给的 LLMConfig 补全成 ResolvedLLMConfig:校验必填的 baseURL/model;对已弃用的 temperature 发警告;给 maxRetries(默认 2)、transformRequestBody(默认恒等函数)、disableNamedToolChoice(默认 false)、customFetch(默认 fetch,且**必须 .bind(globalThis)**否则浏览器里 fetch 会非法,index.ts:107)填默认值。


8. 巧妙之处(可借鉴的技术)

  • 单宏工具替代多工具选择:把 N 个工具 union 成一个 AgentOutput,顺带把反思字段焊进入参 schema——一次调用同时拿到"想法 + 动作",天然实现 ReAct,还回避了野模型对多工具选择的糟糕支持(PageAgentCore.ts:386)。
  • 差异集中在两个点:所有模型方言差异只出现在 modelPatch(出)和 normalizeResponse(回)两处,核心逻辑对所有模型一视同仁,新增模型只需加补丁而不动主干(utils.ts:39autoFixer.ts:21)。
  • 可重试性编码进错误类型:retryableInvokeError 的固有属性而非调用点的临时判断,重试逻辑因此只有三行(errors.ts:52index.ts:70)。
  • 用 schema 自身推断字段名做原语强转:validateAction 找出唯一必填字段,把模型给的裸原语自动装箱,不需要给每个工具写映射(autoFixer.ts:149)。
  • 用提示词而非硬控制打破 wait 死循环:累计等待超阈值就往上下文推一条"别再等了"的观察,把纠偏交还给模型(PageAgentCore.ts:456,540)。
  • 兼容 finish_reason: 'stop' / 'function_call':承认"有些模型调了工具却报 stop""Gemini 叫 function_call",不把它们误判成错误(OpenAIClient.ts:149-152)。

9. 边界与局限(诚实)

  • modelPatch 只认已知模型,且注释自承本地/第三方托管的同名模型 schema 可能不同(utils.ts:36)——它是启发式,兜底还得靠 normalizeResponse 和重试。
  • 重试退避是固定 100ms,没有指数退避(index.ts:78);面对 RATE_LIMIT 可能仍偏激进。
  • normalizeResponse 的"缺动作检测"作者自己标了 todo:靠"没有 action 且没有任一反思字段"来判断只给了动作层,注释写着"needs better detection logic"(autoFixer.ts:63)。
  • temperature 已弃用:parseLLMConfig 会警告,鼓励改用 transformRequestBody 只对验证过的模型设(index.ts:92)。
  • 无流式:OpenAIClient.invoke 是一次性 response.json(),不处理 SSE 流(OpenAIClient.ts:132);每步一个完整响应。

10. 横向对比

同 shelf 的浏览器 agent 里,page-agent 的取舍很鲜明:

  • 它把动作层设计成单个 union 宏工具 + 强制反思,这套心智模型(evaluation/memory/next_goal)与 browser-use 一脉相承(INDEX 注明其 DOM 管线与 prompt 源自 browser-use),但跑在页面客户端内,所以对"用户自带任意模型"的容错要求格外高——modelPatch + autoFixer 这一整套野模型防御是它的重点投资。
  • 相比走 CDP/扩展的方案(如 nanobrowser、agent-browser),page-agent 没有后端可以统一收口模型差异,只能在纯前端把兼容层做厚。

本章与同组其它章的关系:动作在循环里怎么被反复调用01-agent-loop;动作具体在 DOM 上做了什么03-tools-and-actions;本章只管"这一次问模型,怎么问、怎么把答案掰回可用形状"。


11. 代码地图(导航索引)

主题文件路径符号名
宏工具打包(union + 反思字段)packages/core/src/PageAgentCore.ts#packMacroTool
宏工具注册与调用点packages/core/src/PageAgentCore.tsAgentOutput / toolChoiceName
累计等待计数 + 超时提示packages/core/src/PageAgentCore.tstotalWaitTime / #handleObservations
反思三件套语义packages/core/src/types.tsAgentReflection / MacroToolInput
请求组装 + 错误分类 + 执行packages/llms/src/OpenAIClient.tsOpenAIClient.invoke
带重试的入口packages/llms/src/index.tsLLM / withRetry
配置解析与默认值packages/llms/src/index.tsparseLLMConfig
错误分类与可重试名单packages/llms/src/errors.tsInvokeErrorTypes / RETRYABLE_TYPES / InvokeError
按模型改请求方言packages/llms/src/utils.tsmodelPatch / normalizeModelName / getProvider
zod → OpenAI 工具格式packages/llms/src/utils.tszodToOpenAITool
畸形响应修复packages/core/src/utils/autoFixer.tsnormalizeResponse / validateAction
类型契约packages/llms/src/types.tsInvokeOptions / InvokeResult / LLMConfig