Commit 363578ca373adf7ffd21ec33a8833b5dfeef6d9b

Authored by tangwang
1 parent 187f3a6a

**feat: robust thinking support for OpenAI and DashScope**

- **统一并增强 thinking/reasoning 处理的请求参数逻辑(`shopping_agent.py`)**
  - 新增基础工具函数用于判断不同 provider 类型:
    - `_normalize_base_url(base_url)`:标准化 `openai_api_base_url`(去空格与尾部 `/`),避免字符串比较不一致。
    - `_is_openai_official_base_url(base_url)`:通过 hostname 判断是否为官方 `api.openai.com`,用于选择 Responses API。
    - `_is_dashscope_base_url(base_url)`:通过 hostname 中是否包含 `dashscope` 来识别 DashScope 兼容模式。
  - 在 `ShoppingAgent.__init__` 中基于 `base_url` 与 `openai_use_reasoning` 做**分支处理**,确保不同 provider 下的思考模式启用方式正确且互不干扰:
    - **OpenAI 官方(含未显式配置 base_url 的默认情况)**:
      - 当 `openai_use_reasoning=True` 且 `base_url` 为空或指向 `api.openai.com` 时:
        - 启用 `llm_kwargs["use_responses_api"] = True`,切换到 OpenAI Responses API。
        - 设置 `llm_kwargs["model_kwargs"] = {"reasoning": {"effort": <配置>, "summary": "none"}}`,与官方 reasoning 参数保持一致。
    - **DashScope OpenAI 兼容接口**:
      - 当 `openai_use_reasoning=True` 且 `base_url` 解析为 DashScope 域名时:
        - 在请求体中合并注入 `extra_body={"enable_thinking": True}`(保留已有 `extra_body` 字段),按照 DashScope 官方建议开启 Qwen3/QwQ 的思考模式。
        - 不启用 Responses API,依然使用标准 `/chat/completions` 接口,符合 DashScope 兼容模式要求。
    - **其他第三方 OpenAI 兼容服务**:
      - 当 `openai_use_reasoning=True` 且 `base_url` 既非 OpenAI 官方也非 DashScope 时:
        - 不再强行附加任何 provider 特定的 thinking 参数,仅记录一条 info 级别日志说明「请求了 reasoning,但当前 base_url 不识别为 OpenAI 或 DashScope,故跳过 provider-specific 参数」,避免潜在 4xx 报错或不可预期行为。
  - 根据 provider 选择不同的 LLM 类:
    - 对于 **OpenAI 官方 endpoint**(包括默认未设置 `base_url` 时)仍使用原始 `ChatOpenAI`。
    - 对于 **任何非 OpenAI 官方的 base_url**(包含 DashScope 和其他兼容实现),统一使用扩展后的 `ChatOpenAIWithReasoningContent`,保证即使未来有更多兼容服务返回 `reasoning_content` 字段,也能统一注入 `additional_kwargs`。

- **扩展 LLM 响应中的 thinking/reasoning 提取与日志记录(`shopping_agent.py`)**
  - 新增 `_coerce_reasoning_text(value)` 辅助函数,对多种结构的 reasoning 返回进行**鲁棒的文本提取**:
    - 支持 `str`、`dict`、`list` 等多种结构:
      - 对 `dict` 优先尝试聚合 `content` / `summary` / `text` / `reasoning_content` 字段;
      - 对 `list` 递归调用自身并按行拼接;
      - 当无法结构化提取时,兜底使用 `json.dumps(..., ensure_ascii=False)` 或 `str(value)`,避免因结构变更导致完全丢失思考信息。
  - 在 `_extract_thinking(msg)` 中统一使用 `_coerce_reasoning_text`,大幅提高对不同模型/接口返回格式的兼容性:
    - 优先从 `msg.additional_kwargs["reasoning_content"]` 中提取(DashScope/Qwen 官方推荐字段)并返回。
    - 其次从 `msg.additional_kwargs["reasoning"]` 中提取(OpenAI Responses API reasoning 对象)。
    - 再次遍历 `msg.content` 为 list 的情况,识别 `type` 为 `"reasoning" / "reasoning_content" / "thinking"` 的内容块,并通过 `_coerce_reasoning_text` 提取文本。
    - 最后对于纯字符串 content,通过 `_RE_THINK_INNER` 正则匹配 `<think>...</think>` 包裹的思考片段,只返回标签内正文。
  - 在 `_message_for_log` 中增加 `include_thinking` 开关:
    - 当 `include_thinking=True` 时:
      - 调用 `_extract_thinking(msg)` 获取思考内容,并做长度截断(超过 `_LOG_CONTENT_MAX` 时尾部追加 `[truncated, total N chars]` 标记),然后写入 `out["thinking"]` 字段。
    - 日志中区分「正式回复」与「thinking」两个字段,便于后续排查与分析。
  - 调整 `_message_for_log` 对「是否需要走 `_extract_formal_reply`」的判断逻辑:
    - 如 `msg.additional_kwargs` 中存在 `reasoning` 或 `reasoning_content` 字段,则通过 `_extract_formal_reply` 去掉 thinking,只保留正式回复文本;
    - 否则直接使用 `_extract_message_text` 减少不必要处理。
  - 在 LangGraph 的 `agent_node` 中,将 LLM 响应的日志调用更新为:
    - `response_log = _message_for_log(response, include_thinking=True)`
    - 确保每条 `LLM_RESPONSE` 日志都尽量带有 `"thinking"` 字段(在模型真实返回思考内容的前提下),方便线上观测与调试。

- **为兼容模式增加 `reasoning_content` 注入支持(`shopping_agent.py`)**
  - 新增 `ChatOpenAIWithReasoningContent` 子类,继承自 `ChatOpenAI`,重写 `_create_chat_result`:
    - `super()._create_chat_result(response, generation_info)` 后,通过原始 `response`(`dict` 或包含 `model_dump()` 的对象)提取 `choices[i].message.reasoning_content`。
    - 对于每个 choice,如存在 `reasoning_content` 字段,则将其写入对应 `AIMessage` 的 `additional_kwargs["reasoning_content"]` 中。
    - 该注入逻辑是幂等的、仅在字段存在时生效,对不返回 reasoning_content 的模型/服务没有副作用。
  - 该子类专门用于 DashScope 及未来可能返回 `reasoning_content` 的其他兼容 provider,将 provider 特定逻辑集中在一处,方便维护。

- **补充与修正与 thinking 相关的正则和提取逻辑(`shopping_agent.py`)**
  - 在原有 `_RE_THINK_TAGS` 基础上新增加 `_RE_THINK_INNER`:
    - `_RE_THINK_TAGS`:用于从完整回复中移除 `<think>...</think>` 块,供 `_extract_formal_reply` 使用。
    - `_RE_THINK_INNER`:用于仅提取 `<think>...</think>` 标签内部正文,供 `_extract_thinking` 使用,避免日志中重复包含标签本身。

- **更新配置注释以匹配新的 reasoning 行为(`config.py`)**
  - 改写 `openai_use_reasoning` 的注释,使其准确描述在不同 provider 下的启用方式:
    - OpenAI 官方 endpoint(含 `api.openai.com` base_url):通过 Responses API 的 `reasoning` 参数启用思考模式。
    - DashScope 兼容 endpoint:通过 `extra_body.enable_thinking=True` 开启思考模式,由模型返回 `reasoning_content`。
  - 将 `openai_use_reasoning` 默认值设置为 `True`,以便在满足条件时自动启用 reasoning,同时由上游配置控制具体是否生效。
Showing 2 changed files with 160 additions and 9 deletions   Show diff stats
app/agents/shopping_agent.py
... ... @@ -11,11 +11,13 @@ Architecture:
11 11 import json
12 12 import logging
13 13 import re
  14 +from urllib.parse import urlparse
14 15 from datetime import datetime
15 16 from pathlib import Path
16 17 from typing import Any, Optional, Sequence
17 18  
18 19 from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
  20 +from langchain_core.outputs import ChatResult
19 21 from langchain_openai import ChatOpenAI
20 22 from langgraph.checkpoint.memory import MemorySaver
21 23 from langgraph.graph import END, START, StateGraph
... ... @@ -110,6 +112,63 @@ def _extract_message_text(msg) -&gt; str:
110 112  
111 113 # 部分 API(如 DeepSeek)在 content 中返回 think 标签块,需去掉后只保留正式回复
112 114 _RE_THINK_TAGS = re.compile(r"<think>.*?<\/think>", re.DOTALL | re.IGNORECASE)
  115 +# 仅提取 <think> 标签内正文(用于日志打印 thinking)
  116 +_RE_THINK_INNER = re.compile(r"<think>(.*?)<\/think>", re.DOTALL | re.IGNORECASE)
  117 +
  118 +
  119 +def _normalize_base_url(base_url: Optional[str]) -> str:
  120 + return (base_url or "").strip().rstrip("/")
  121 +
  122 +
  123 +def _is_openai_official_base_url(base_url: Optional[str]) -> bool:
  124 + normalized = _normalize_base_url(base_url)
  125 + if not normalized:
  126 + return False
  127 + hostname = (urlparse(normalized).hostname or "").lower()
  128 + return hostname.endswith("api.openai.com")
  129 +
  130 +
  131 +def _is_dashscope_base_url(base_url: Optional[str]) -> bool:
  132 + normalized = _normalize_base_url(base_url)
  133 + if not normalized:
  134 + return False
  135 + hostname = (urlparse(normalized).hostname or "").lower()
  136 + return "dashscope" in hostname
  137 +
  138 +
  139 +def _coerce_reasoning_text(value: Any) -> str:
  140 + """Best-effort conversion from reasoning payload to plain text."""
  141 + if value is None:
  142 + return ""
  143 + if isinstance(value, str):
  144 + return value.strip()
  145 + if isinstance(value, dict):
  146 + parts: list[str] = []
  147 + for key in ("content", "summary", "text", "reasoning_content"):
  148 + item = value.get(key)
  149 + if isinstance(item, str) and item.strip():
  150 + parts.append(item.strip())
  151 + elif isinstance(item, list):
  152 + for sub in item:
  153 + s = _coerce_reasoning_text(sub)
  154 + if s:
  155 + parts.append(s)
  156 + if parts:
  157 + return "\n".join(parts).strip()
  158 + try:
  159 + return json.dumps(value, ensure_ascii=False)
  160 + except Exception:
  161 + return str(value).strip()
  162 + if isinstance(value, list):
  163 + parts = [_coerce_reasoning_text(v) for v in value]
  164 + joined = "\n".join(p for p in parts if p)
  165 + if joined:
  166 + return joined.strip()
  167 + try:
  168 + return json.dumps(value, ensure_ascii=False)
  169 + except Exception:
  170 + return str(value).strip()
  171 + return str(value).strip()
113 172  
114 173  
115 174 def _extract_formal_reply(msg) -> str:
... ... @@ -136,9 +195,42 @@ def _extract_formal_reply(msg) -&gt; str:
136 195 return str(content).strip() if content else ""
137 196  
138 197  
139   -def _message_for_log(msg: BaseMessage) -> dict:
  198 +def _extract_thinking(msg) -> str:
  199 + """提取大模型回复中的 thinking/reasoning 内容(仅用于日志)。"""
  200 + kwargs = getattr(msg, "additional_kwargs", None) or {}
  201 + # DashScope 等兼容接口返回的 reasoning_content(由 ChatOpenAIWithReasoningContent 注入)
  202 + rc = _coerce_reasoning_text(kwargs.get("reasoning_content"))
  203 + if rc:
  204 + return rc
  205 + # Responses API 等返回的 reasoning 字段
  206 + reasoning = _coerce_reasoning_text(kwargs.get("reasoning"))
  207 + if reasoning:
  208 + return reasoning
  209 + content = getattr(msg, "content", "")
  210 + if isinstance(content, list):
  211 + parts = []
  212 + for block in content:
  213 + if not isinstance(block, dict):
  214 + continue
  215 + block_type = (block.get("type") or "").lower()
  216 + if block_type not in ("reasoning", "reasoning_content", "thinking"):
  217 + continue
  218 + text = _coerce_reasoning_text(block.get("text") or block.get("content") or block)
  219 + if text:
  220 + parts.append(text)
  221 + if parts:
  222 + return "".join(str(p) for p in parts).strip()
  223 + if isinstance(content, str):
  224 + m = _RE_THINK_INNER.search(content)
  225 + if m:
  226 + return m.group(1).strip()
  227 + return ""
  228 +
  229 +
  230 +def _message_for_log(msg: BaseMessage, include_thinking: bool = False) -> dict:
140 231 """Serialize a message for structured logging (content truncated)."""
141   - if getattr(msg, "additional_kwargs", None) and "reasoning" in (msg.additional_kwargs or {}):
  232 + msg_kwargs = getattr(msg, "additional_kwargs", None) or {}
  233 + if msg_kwargs and any(k in msg_kwargs for k in ("reasoning", "reasoning_content")):
142 234 text = _extract_formal_reply(msg) or _extract_message_text(msg)
143 235 else:
144 236 text = _extract_message_text(msg)
... ... @@ -148,6 +240,12 @@ def _message_for_log(msg: BaseMessage) -&gt; dict:
148 240 "type": getattr(msg, "type", "unknown"),
149 241 "content": text,
150 242 }
  243 + if include_thinking:
  244 + thinking = _extract_thinking(msg)
  245 + if thinking:
  246 + if len(thinking) > _LOG_CONTENT_MAX:
  247 + thinking = thinking[:_LOG_CONTENT_MAX] + f"... [truncated, total {len(thinking)} chars]"
  248 + out["thinking"] = thinking
151 249 if hasattr(msg, "tool_calls") and msg.tool_calls:
152 250 out["tool_calls"] = [
153 251 {"name": tc.get("name"), "args": tc.get("args", {})}
... ... @@ -156,6 +254,38 @@ def _message_for_log(msg: BaseMessage) -&gt; dict:
156 254 return out
157 255  
158 256  
  257 +# ── DashScope thinking 支持 ─────────────────────────────────────────────────────
  258 +# LangChain 解析 chat completion 时不会把 API 返回的 reasoning_content 写入 message,
  259 +# 子类在 _create_chat_result 中把 reasoning_content 注入到 additional_kwargs,便于日志打印。
  260 +
  261 +class ChatOpenAIWithReasoningContent(ChatOpenAI):
  262 + """ChatOpenAI 子类:将 API 返回的 reasoning_content 注入到 message.additional_kwargs。"""
  263 +
  264 + def _create_chat_result(
  265 + self,
  266 + response: Any,
  267 + generation_info: Optional[dict] = None,
  268 + ) -> ChatResult:
  269 + result = super()._create_chat_result(response, generation_info)
  270 + if isinstance(response, dict):
  271 + response_dict = response
  272 + else:
  273 + response_dict = getattr(response, "model_dump", None)
  274 + response_dict = response_dict() if callable(response_dict) else {}
  275 + if not response_dict:
  276 + return result
  277 + choices = response_dict.get("choices") or []
  278 + for i, res in enumerate(choices):
  279 + if i >= len(result.generations):
  280 + break
  281 + msg_dict = res.get("message") or {}
  282 + if isinstance(msg_dict, dict) and "reasoning_content" in msg_dict:
  283 + rc = msg_dict["reasoning_content"]
  284 + if rc and isinstance(result.generations[i].message, BaseMessage):
  285 + result.generations[i].message.additional_kwargs["reasoning_content"] = rc
  286 + return result
  287 +
  288 +
159 289 # ── Agent class ────────────────────────────────────────────────────────────────
160 290  
161 291 class ShoppingAgent:
... ... @@ -169,14 +299,33 @@ class ShoppingAgent:
169 299 temperature=settings.openai_temperature,
170 300 api_key=settings.openai_api_key,
171 301 )
172   - if settings.openai_api_base_url:
173   - llm_kwargs["base_url"] = settings.openai_api_base_url
174   - if getattr(settings, "openai_use_reasoning", False):
  302 + base_url = _normalize_base_url(settings.openai_api_base_url)
  303 + if base_url:
  304 + llm_kwargs["base_url"] = base_url
  305 +
  306 + use_reasoning = getattr(settings, "openai_use_reasoning", False)
  307 + if use_reasoning and (not base_url or _is_openai_official_base_url(base_url)):
  308 + # OpenAI 官方 endpoint:使用 Responses API 的 reasoning 参数。
175 309 llm_kwargs["use_responses_api"] = True
176 310 effort = getattr(settings, "openai_reasoning_effort", "medium") or "medium"
177 311 llm_kwargs["model_kwargs"] = {"reasoning": {"effort": effort, "summary": "none"}}
  312 + elif use_reasoning and _is_dashscope_base_url(base_url):
  313 + # DashScope 兼容 endpoint:通过 extra_body 开启思考,返回 reasoning_content。
  314 + extra = llm_kwargs.get("extra_body") or {}
  315 + llm_kwargs["extra_body"] = {**extra, "enable_thinking": True}
  316 + elif use_reasoning and base_url:
  317 + logger.info(
  318 + "Reasoning requested but base_url is non-OpenAI/non-DashScope; "
  319 + "skipping provider-specific reasoning params. base_url=%s",
  320 + base_url,
  321 + )
178 322  
179   - self.llm = ChatOpenAI(**llm_kwargs)
  323 + llm_class = (
  324 + ChatOpenAIWithReasoningContent
  325 + if base_url and not _is_openai_official_base_url(base_url)
  326 + else ChatOpenAI
  327 + )
  328 + self.llm = llm_class(**llm_kwargs)
180 329  
181 330 # Tools are session-bound so search_products writes to the right registry partition
182 331 self.tools = get_all_tools(session_id=self.session_id, registry=global_registry)
... ... @@ -196,7 +345,7 @@ class ShoppingAgent:
196 345 req_json = req_json[:_LOG_CONTENT_MAX] + f"... [truncated total {len(req_json)}]"
197 346 logger.info("[%s] LLM_REQUEST messages=%s", self.session_id, req_json)
198 347 response = self.llm_with_tools.invoke(messages)
199   - response_log = _message_for_log(response)
  348 + response_log = _message_for_log(response, include_thinking=True)
200 349 logger.info(
201 350 "[%s] LLM_RESPONSE %s",
202 351 self.session_id,
... ...
app/config.py
... ... @@ -33,8 +33,10 @@ class Settings(BaseSettings):
33 33 openai_vision_model: str = "qwen3-omni-flash"
34 34 openai_temperature: float = 0.7
35 35 openai_max_tokens: int = 1000
36   - # 对话调用大模型时是否开启 thinking(需兼容 Responses API / reasoning 的模型,如 o1/o3/o4-mini)
37   - openai_use_reasoning: bool = False
  36 + # 对话调用大模型时是否开启 thinking:
  37 + # - OpenAI 官方 endpoint(含 api.openai.com base_url):走 Responses API reasoning
  38 + # - DashScope 兼容 endpoint:通过 extra_body.enable_thinking 开启
  39 + openai_use_reasoning: bool = True
38 40 openai_reasoning_effort: str = "medium" # low | medium | high
39 41 # Base URL for OpenAI-compatible APIs (e.g. Qwen/DashScope)
40 42 # Qwen 北京: https://dashscope.aliyuncs.com/compatible-mode/v1
... ...