ambiguous-pointer commented on PR #1585:
URL: https://github.com/apache/dubbo-admin/pull/1585#issuecomment-5995878140

   ## 设计思路:如何吸收 pi-ai 与 Pydantic AI 的经验
   
   这条评论补充一下这个 PR 
的**设计来源**——它不是凭空写出来的,而是先把两个成熟实现读透、拆解它们解决的是不是同一个问题之后才动的手。也顺便说明哪些地方我**没有**采纳,以及为什么。
   
   ---
   
   ### 一、先分清:两个项目问的不是同一个问题
   
   这是整个设计的起点,也是我花最多时间确认的事。
   
   | | pi-ai 的 `compat` | Pydantic AI 的 `ModelProfile` |
   |---|---|---|
   | 问的问题 | 「**这个端点**有什么怪癖?」 | 「**这个模型**支持什么能力?」 |
   | 数据结构 | 端点 quirk 开关集合 | 模型能力描述符 |
   | 默认值来源 | `baseUrl` 启发式探测 | client class 分派 + 厂商 profile 函数 |
   
   举个具体例子就明白了。接一个 OpenAI 兼容端点,要回答五个问题:
   
   1. 这个模型会思考吗? → **能力**
   2. 思考放在哪个字段? → **怪癖**
   3. 怎么打开? → **怪癖**
   4. 要不要回传? → **怪癖**
   5. 思考和答案共享 `max_tokens` 吗? → **怪癖**
   
   **五个里四个是怪癖。** 所以纯能力语义(只做 Pydantic AI)覆盖不了"怎么发"这一半;纯 quirk 语义(只做 
pi-ai)会让能力判断和参数拼装纠缠在一起。这是我决定做混合路线的直接原因。
   
   ---
   
   ### 二、从 Pydantic AI 学到的(且直接影响了 ADR)
   
   #### 1. 降级语义必须写进契约,不能藏在实现里
   
   Pydantic AI 每个 profile 字段的 JSDoc 都明确写「为 false 时怎么办」,最典型的是 
`supports_forced_tool_choice`:
   
   > When False, a forced tool choice that Pydantic AI resolved itself falls 
back to `'auto'`... **An explicit forcing `tool_choice` raises a `UserError`.**
   
   这个「框架自己解析的 → 静默降级;用户显式配置的 → 
报错」的二分,是我读过最有价值的一个设计。理由很直白:用户没要求的事不该让请求失败,而用户明确表达意图的事被悄悄改掉等于欺骗。
   
   **这直接影响了我这里的行为**:思考预算不是「用户开了思考才封顶」,而是「配了就发」。因为 `qwen3.5-plus` 
思考默认开启——**"用户没配置"恰恰是最需要封顶的情况**。我第一版实现写错了(只在 `reasoningEffort != ""` 
时发),是被这个思路纠正的。
   
   #### 2. 禁止按 base_url 收窄能力(→ ADR-003)
   
   Pydantic AI 的 `models/AGENTS.md` 有一条硬规则:
   
   > **Narrow a capability by _client class_, never by the client's 
`base_url`.**
   
   pi-ai 恰恰相反,`detectCompat()` 整个建立在 `baseUrl.includes(...)` 
上。我**明确没有采纳这一条**,理由是 dubbo-admin 的场景会把它坑死:one-api / 企业网关代理 DeepSeek 时,URL 里没有 
`deepseek.com`,探测会给出"这是标准 OpenAI"的答案——**而且是自信地给错**。
   
   所以这里没有一行 URL 嗅探逻辑,怪癖由 `models.yaml` 明写。
   
   #### 3. 宁缺勿猜
   
   Pydantic AI 处理 DeepSeek 缓存时的注释很值得抄:
   
   > `default_cache_retention` is **intentionally left unset** (None): 
厂商文档只说未用缓存「会在几小时到几天内自动清理」,**没有承诺保留下限,所以我们无法预测冷缓存**。
   
   这个态度我直接用在了默认值策略上:**未配置的字段取「兼容性最广的那个」,而不是「最像 OpenAI 的那个」**。比如 
`max_tokens_field` 默认 `max_tokens`(老端点普遍只认它),而不是 OpenAI 现在推荐的 
`max_completion_tokens`。
   
   #### 4. 枚举具体 id,不写推导规则
   
   Pydantic AI 的模型能力表明确要求 *list concrete released ids only*,而不是 `qwen3-` 
这种前缀推导——因为 `qwen3-coder`、`qwen3-plus`、`qwen3.5` 行为都不一样,前缀会静默错配。
   
   目前我还没建能力表(只有两个 provider),但这个约束我记下了,等表变大时按它来。
   
   #### 5. fail fast 的配对校验
   
   Pydantic AI 有 `validate_openai_profile()`,在**构造期**就拒绝"开了 field 
模式却没给字段名"这种组合。我照搬了这个:`send_back_thinking: "field"` 必须配 `thinking_field`,否则启动就失败。
   
   理由是这类错误的表现形态太隐蔽——配错的运行时不报错,只是思考内容悄悄消失,然后变成一个没人能查的 bug。
   
   ---
   
   ### 三、从 pi-ai 学到的
   
   #### 1. `thinkingFormat` 的 11 个分支(这是能力语义解决不了的)
   
   读到这里我才真正理解"参数发法"是一个**独立于能力**的问题。pi-ai 的 11 个分支覆盖了 `enable_thinking` / 
`thinking:{type}` / `reasoning:{effort}` / `reasoning:{enabled}` / 顶层 
`thinking: "<字符串>"` / `chat_template_kwargs` / `chat_template_args`……
   
   这一块 Pydantic AI 基本没有(它只针对 OpenAI 做了 `openai_*` 前缀的子类),而 dubbo-admin 
是**多厂商**场景,必须有。所以 `Compat` 里既有能力字段,也有 `ThinkingFormat` 这个发法枚举。
   
   #### 2. `reasoningFields` 数组探测 —— 三行代码替代 N 个 if
   
   ```ts
   const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
   // 取第一个非空的
   ```
   
   这条给我的冲击比看起来大。它防的是:**端点改名,客户端写死字段名,静默读到空**。vLLM 真的发生过——把 `reasoning_content` 
重命名成 `reasoning`,旧客户端不报错,只是拿到空串。
   
   所以我这边是 `ThinkingFields` 数组 + 
`ExtractThinking`,并**额外记录命中的字段名**,回传时原路送回(Pydantic AI 的做法,它还会校验 provider 
是否一致,避免把 A 的思考发给 B)。
   
   #### 3. `thinkingTokenBudgetField` —— 一句 JSDoc 讲清了一个真坑
   
   > Reasoning and the answer share `max_tokens`,**没有预算的 reasoning-heavy 
轮次会吃光整个响应,且不产出任何答案**。
   
   pi-ai 把 vLLM / Qwen-DashScope-SGLang / llama.cpp 
三种字段名收敛成一个枚举。**这句话后来被实测完全证实**(见下)。
   
   #### 4. 逐字段 `??` 合并
   
   `getCompat()` 每个字段独立 nullish 合并,而不是整体替换。用户只写一个字段,其余保持探测值。这个模式我在两极合并里保留了。
   
   ---
   
   ### 四、明确没有采纳的
   
   这部分可能比"借鉴了什么"更值得看。
   
   | 来源 | 做法 | 为什么没要 |
   |---|---|---|
   | pi-ai | 按 `baseUrl` 自动探测 | 网关会骗过它;而且 dubbo-admin 的 provider 是配置出来的,不是猜出来的 |
   | pi-ai | 三条件回填 `reasoning_content` | 条件过宽——它对**所有** assistant 
消息注入空串,包括纯聊天轮次。改用 Pydantic 的四条件(思考发生过 + field 模式 + assistant + 有 tool_calls)|
   | pi-ai | 穷举式 `isXxx` 判断 | 每加一个端点就要改代码;Pydantic 用 
`openai_unsupported_model_settings` 这种黑名单,新增模型零成本 |
   | Pydantic | 三层 Model/Provider/Profile | dubbo-admin 只有一种协议(OpenAI 
兼容),三层是过度设计。降为两级(provider / model)|
   | Pydantic | 完整的 Profile 机制 | 同上。当前只需约 200 行 |
   
   还有一个我自己踩的坑值得记:一开始我用 `override || base` 合并普通布尔,结果**模型级无法覆盖 provider 级的 
true**,违反"逐字段覆盖"。测试抓出来后全部改成 `*bool`。这个 bug 说明「未设置」和「显式 false」必须可区分——在 YAML 
层面天然可区分,在 Go 层面不特意做就丢了。
   
   ---
   
   ### 五、实测如何反过来推翻了调研结论
   
   上面所有内容在写代码时都还是"搜索摘要级"证据。同伴会话后来澄清:**它没做过任何 live API 测试**。所以我真调了 
DashScope,结果推翻了两条:
   
   - **ADR-009 被证伪。** 之前标为 P0 的"非流式 vs 思考冲突",实测 `qwen3.5-plus` + 
`enable_thinking=true` + `stream=false` 返回 200。那条 stream-only 限制只针对**开源权重版 
Qwen3 的自建部署**。→ **不需要重写调用路径为流式**,ADR 关闭。
   - **发现思考默认开启。** 不带 `enable_thinking` 也返回 `reasoning_content`。而 genkit 
不回读这个字段 —— 意味着**每轮都在付钱、占预算、然后被丢弃**。这就是"预算不依赖显式 effort 也要发"的由来。
   
   还有一个语义补充:`tool_choice: "required"` 在 Qwen 
上是**静默忽略**而非报错——问一个不需要工具的问题,`required` 和 `auto` 行为完全一致。这比我原本理解的"不支持"危险得多:agent 
以为能强制,既没报错也没工具调用。`supports_forced_tool_choice` 这个能力位因此不能只用来决定"要不要报错"。
   
   ---
   
   ### 六、想请评审者一起看的开放问题
   
   **1. `Compat` 目前只够得着请求体的顶层。**
   `strict`、system 角色、消息顺序由 genkit 的 `ai.WithTools` / `ai.WithSystem` / 
`ai.WithMessages` 决定,本层够不着。相关字段定义了、解析了、有文档,但**没有效果**。这是诚实的现状,不是遗漏。
   
   **2. 响应侧是通的,但没接。**
   genkit 的 `compat_oai` 只映射 `Content` 和 `ToolCalls`,所以 `ExtractThinking` 
没有调用点。实测百炼不强制回传,所以对当前配置无害;但对 DeepSeek / vLLM 是**失效的**。三条解法(给 genkit 提 PR / 自建 
Model 绕开 / 另开通道)我都没选,想听听倾向。
   
   **3. `thinking_budget: -1` 未验证。**
   代码发 `-1` 表示"端点默认",实测只验过显式正数(设 100 → `reasoning_tokens` 恰好 100)。
   
   **4. 那两个 P0 比这个 PR 严重。**
   后端静默丢弃前端发的 `context` 字段、端点零认证(实测 `GET /api/v1/ai/sessions` 无凭据返回 200)。本 PR 
完全没碰。
   
   ---
   
   如果对上面任何一条的理解有偏差,尤其是第三节里"哪些不该抄"的判断,欢迎直接指出来。
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to