Commit 363578ca373adf7ffd21ec33a8833b5dfeef6d9b
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) -> 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) -> 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) -> 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) -> 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 | ... | ... |