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 +11,13 @@ Architecture:
11 import json 11 import json
12 import logging 12 import logging
13 import re 13 import re
  14 +from urllib.parse import urlparse
14 from datetime import datetime 15 from datetime import datetime
15 from pathlib import Path 16 from pathlib import Path
16 from typing import Any, Optional, Sequence 17 from typing import Any, Optional, Sequence
17 18
18 from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage 19 from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
  20 +from langchain_core.outputs import ChatResult
19 from langchain_openai import ChatOpenAI 21 from langchain_openai import ChatOpenAI
20 from langgraph.checkpoint.memory import MemorySaver 22 from langgraph.checkpoint.memory import MemorySaver
21 from langgraph.graph import END, START, StateGraph 23 from langgraph.graph import END, START, StateGraph
@@ -110,6 +112,63 @@ def _extract_message_text(msg) -&gt; str: @@ -110,6 +112,63 @@ def _extract_message_text(msg) -&gt; str:
110 112
111 # 部分 API(如 DeepSeek)在 content 中返回 think 标签块,需去掉后只保留正式回复 113 # 部分 API(如 DeepSeek)在 content 中返回 think 标签块,需去掉后只保留正式回复
112 _RE_THINK_TAGS = re.compile(r"<think>.*?<\/think>", re.DOTALL | re.IGNORECASE) 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 def _extract_formal_reply(msg) -> str: 174 def _extract_formal_reply(msg) -> str:
@@ -136,9 +195,42 @@ def _extract_formal_reply(msg) -&gt; str: @@ -136,9 +195,42 @@ def _extract_formal_reply(msg) -&gt; str:
136 return str(content).strip() if content else "" 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 """Serialize a message for structured logging (content truncated).""" 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 text = _extract_formal_reply(msg) or _extract_message_text(msg) 234 text = _extract_formal_reply(msg) or _extract_message_text(msg)
143 else: 235 else:
144 text = _extract_message_text(msg) 236 text = _extract_message_text(msg)
@@ -148,6 +240,12 @@ def _message_for_log(msg: BaseMessage) -&gt; dict: @@ -148,6 +240,12 @@ def _message_for_log(msg: BaseMessage) -&gt; dict:
148 "type": getattr(msg, "type", "unknown"), 240 "type": getattr(msg, "type", "unknown"),
149 "content": text, 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 if hasattr(msg, "tool_calls") and msg.tool_calls: 249 if hasattr(msg, "tool_calls") and msg.tool_calls:
152 out["tool_calls"] = [ 250 out["tool_calls"] = [
153 {"name": tc.get("name"), "args": tc.get("args", {})} 251 {"name": tc.get("name"), "args": tc.get("args", {})}
@@ -156,6 +254,38 @@ def _message_for_log(msg: BaseMessage) -&gt; dict: @@ -156,6 +254,38 @@ def _message_for_log(msg: BaseMessage) -&gt; dict:
156 return out 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 # ── Agent class ──────────────────────────────────────────────────────────────── 289 # ── Agent class ────────────────────────────────────────────────────────────────
160 290
161 class ShoppingAgent: 291 class ShoppingAgent:
@@ -169,14 +299,33 @@ class ShoppingAgent: @@ -169,14 +299,33 @@ class ShoppingAgent:
169 temperature=settings.openai_temperature, 299 temperature=settings.openai_temperature,
170 api_key=settings.openai_api_key, 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 llm_kwargs["use_responses_api"] = True 309 llm_kwargs["use_responses_api"] = True
176 effort = getattr(settings, "openai_reasoning_effort", "medium") or "medium" 310 effort = getattr(settings, "openai_reasoning_effort", "medium") or "medium"
177 llm_kwargs["model_kwargs"] = {"reasoning": {"effort": effort, "summary": "none"}} 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 # Tools are session-bound so search_products writes to the right registry partition 330 # Tools are session-bound so search_products writes to the right registry partition
182 self.tools = get_all_tools(session_id=self.session_id, registry=global_registry) 331 self.tools = get_all_tools(session_id=self.session_id, registry=global_registry)
@@ -196,7 +345,7 @@ class ShoppingAgent: @@ -196,7 +345,7 @@ class ShoppingAgent:
196 req_json = req_json[:_LOG_CONTENT_MAX] + f"... [truncated total {len(req_json)}]" 345 req_json = req_json[:_LOG_CONTENT_MAX] + f"... [truncated total {len(req_json)}]"
197 logger.info("[%s] LLM_REQUEST messages=%s", self.session_id, req_json) 346 logger.info("[%s] LLM_REQUEST messages=%s", self.session_id, req_json)
198 response = self.llm_with_tools.invoke(messages) 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 logger.info( 349 logger.info(
201 "[%s] LLM_RESPONSE %s", 350 "[%s] LLM_RESPONSE %s",
202 self.session_id, 351 self.session_id,
@@ -33,8 +33,10 @@ class Settings(BaseSettings): @@ -33,8 +33,10 @@ class Settings(BaseSettings):
33 openai_vision_model: str = "qwen3-omni-flash" 33 openai_vision_model: str = "qwen3-omni-flash"
34 openai_temperature: float = 0.7 34 openai_temperature: float = 0.7
35 openai_max_tokens: int = 1000 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 openai_reasoning_effort: str = "medium" # low | medium | high 40 openai_reasoning_effort: str = "medium" # low | medium | high
39 # Base URL for OpenAI-compatible APIs (e.g. Qwen/DashScope) 41 # Base URL for OpenAI-compatible APIs (e.g. Qwen/DashScope)
40 # Qwen 北京: https://dashscope.aliyuncs.com/compatible-mode/v1 42 # Qwen 北京: https://dashscope.aliyuncs.com/compatible-mode/v1