From 24e92c437d5f64277ecdb85d539d24acd8262034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 29 May 2026 22:16:19 +0800 Subject: [PATCH] =?UTF-8?q?ai=E5=86=99=E4=BD=9Cbug=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/backend/config.yaml | 12 +++++- .../agents/ai_writing/nodes/_utils.py | 37 +++++++++++++++++++ .../agents/ai_writing/nodes/editor.py | 8 +++- .../agents/ai_writing/nodes/intent_parser.py | 11 ++++++ .../agents/ai_writing/nodes/writer_draft.py | 15 +++++++- .../agents/ai_writing/nodes/writer_outline.py | 10 +++-- .../agents/ai_writing/search/skill_search.py | 5 +++ .../deerflow/agents/ai_writing/state.py | 5 +++ 8 files changed, 95 insertions(+), 8 deletions(-) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 9a27a6ce..ace92056 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: zai-org/GLM-5-FP8 use: langchain_openai:ChatOpenAI model: zai-org/GLM-5-FP8 - api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA + api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA base_url: https://api.us-west-2.modal.direct/v1 request_timeout: 600.0 max_retries: 2 @@ -71,6 +71,16 @@ models: temperature: 1.0 supports_vision: true supports_thinking: true + - name: glm-5.1 + display_name: glm-5.1 + use: langchain_openai:ChatOpenAI + model: glm-5.1 + api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR + base_url: https://open.bigmodel.cn/api/paas/v4/ + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true title: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py index 4a6ff0fa..3f34cf90 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py @@ -74,6 +74,28 @@ def get_model(config: RunnableConfig, model_name: str | None = None): return create_chat_model(name=name) +def get_writing_model(config: RunnableConfig, state: dict): + """大纲 / 写作 / 审核专用:严格使用用户在表单选定的模型,**不回退默认**。 + + 用户模型在 intent_parser(首次 invocation,configurable.model_name 可用)阶段 + 捕获进 ``state['model_name']``,经 checkpoint 持久化。这三个节点都在 + 「确认素材 / 大纲」resume 之后才运行,而 resume 是新 invocation、其 config 不再 + 带 model_name —— 所以必须从 state 读,不能从 configurable 读(否则回退到 config + 列表第一个模型,常是推理模型,既影响质量也会让 reasoning_content 走不出流式)。 + + 缺失视为配置错误直接抛 ValueError —— 由各节点的 try/except 兜底成 status:error, + 而不是静默回退默认模型。 + """ + name = ((state or {}).get("model_name") or "").strip() + if not name: + # 兜底再看一眼 configurable(首次同 invocation 内直跑到这里的极端情况) + name = (config.get("configurable", {}).get("model_name", "") or "").strip() + if not name: + raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试") + from deerflow.models import create_chat_model + return create_chat_model(name=name) + + # 弱模型常把结构性标点写成中文全角符号,仅在常规解析失败后作为修复手段 _FULLWIDTH_FIX = { "“": '"', # 左双引号 “ @@ -175,6 +197,21 @@ def parse_json(text: str, fallback: dict) -> dict: return fallback +def reasoning_delta(chunk) -> str: + """提取流式 chunk 的增量「推理内容」。 + + 推理模型(deepseek-reasoner / GLM 等)会把思考过程放在 + ``additional_kwargs.reasoning_content`` 增量字段里,而 ``content`` 为空 —— + 只读 content 的流式循环会把这些 chunk 全部跳过,前端就看不到任何实时输出。 + 各写作节点用本函数把推理增量当作 ``is_thinking=True`` 推给前端的思考框。 + + 兼容不同 provider 的字段名:``reasoning_content``(主流)/ ``reasoning``。 + """ + ak = getattr(chunk, "additional_kwargs", None) or {} + val = ak.get("reasoning_content") or ak.get("reasoning") or "" + return val if isinstance(val, str) else "" + + def _content_to_text(content) -> str: """将 LLM 响应内容统一为字符串(部分模型返回结构化 content 块列表)。""" if isinstance(content, str): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index 049bc178..3b0c9e5d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -11,7 +11,7 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue from ..prompts.editor_prompts import ( EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, ) -from ._utils import get_ai_writing_config, get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer +from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -49,6 +49,10 @@ async def _stream_one_review( SystemMessage(content=EDITOR_SYSTEM), HumanMessage(content=user_prompt), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "review_chunk", "category": category, "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue @@ -118,7 +122,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 try: - model = get_model(config) + model = get_writing_model(config, state) except Exception as e: logger.error("editor model creation failed: %s", e, exc_info=True) return { diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index d5008461..ac94bc91 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -14,6 +14,7 @@ from ._utils import ( _content_to_text, get_model, parse_json, + reasoning_delta, safe_get_stream_writer, ) @@ -215,6 +216,10 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d async for chunk in model.astream( [SystemMessage(content=system), HumanMessage(content=user)] ): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue @@ -259,6 +264,12 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) updates: dict = {"status": "researching", "progress_events": progress_events} + # 捕获用户表单选定的主模型进 state(仅本节点能可靠拿到 configurable.model_name — + # 它运行在首次 invocation;后续大纲/写作/审核在 resume 后跑,config 已不带它)。 + # 大纲/写作/审核经 get_writing_model 严格读 state['model_name'],不回退默认。 + selected_model = (configurable.get("model_name") or "").strip() + if selected_model: + updates["model_name"] = selected_model updates["article_type"] = final_article_type updates["target_audience"] = final_target_audience updates["word_count_target"] = final_word_count diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 5578a6f5..9313bdb0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -12,7 +12,7 @@ from ..prompts.writer_prompts import ( DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, ) -from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, safe_get_stream_writer +from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -197,7 +197,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 try: - model = get_model(config) + model = get_writing_model(config, state) except Exception as e: logger.error("draft model creation failed: %s", e, exc_info=True) return { @@ -250,6 +250,17 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di SystemMessage(content=DRAFT_SECTION_SYSTEM), HumanMessage(content=user_msg), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({ + "type": "draft_chunk", + "section_index": i, + "section_title": sec_title, + "text": reasoning, + "is_thinking": True, + "is_section_done": False, + }) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index cf8c01df..566011b2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -11,7 +11,7 @@ from ..state import AIWritingState, KeyPoint, Outline, OutlineSection from ..prompts.writer_prompts import ( OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, ) -from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer +from ._utils import get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -125,13 +125,17 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> parser = ThinkTagParser() try: - # get_model 放进 try:模型名配错时 create_chat_model 抛 ValueError, + # get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError, # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 - model = get_model(config) + model = get_writing_model(config, state) async for chunk in model.astream([ SystemMessage(content=system_prompt), HumanMessage(content=user_prompt), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py index 1b4ea6e0..d28e79c7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py @@ -26,6 +26,7 @@ from ..nodes._utils import ( _content_to_text, get_model, parse_json, + reasoning_delta, safe_get_stream_writer, ) @@ -163,6 +164,10 @@ class SkillPicker: max_select=self._max_select, )), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + _emit_chunk(reasoning, True) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py index 2420ef50..90074818 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py @@ -132,6 +132,11 @@ class AIWritingState(TypedDict): keyword_count: int # 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) writing_mode: str + # 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。 + # 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state, + # 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原 + # model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。 + model_name: str # Agent A 输出 material_package: Optional[MaterialPackage]