From b25c4e33cb02d91a03e298178a12b4b85fc50e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 18 May 2026 22:41:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/ai_writing/nodes/_utils.py | 22 +++++--- .../agents/ai_writing/nodes/editor.py | 14 ++++- .../agents/ai_writing/nodes/intent_parser.py | 51 ++++++++++++++++--- .../agents/ai_writing/nodes/researcher.py | 9 +++- .../agents/ai_writing/nodes/writer_draft.py | 14 ++++- .../agents/ai_writing/nodes/writer_outline.py | 4 +- .../ai_writing/prompts/researcher_prompts.py | 10 ++++ 7 files changed, 108 insertions(+), 16 deletions(-) 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 f5877ecb..9d2085d7 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 @@ -15,10 +15,15 @@ from langchain_core.runnables import RunnableConfig logger = logging.getLogger(__name__) -def get_model(config: RunnableConfig): +def get_model(config: RunnableConfig, model_name: str | None = None): + """创建对话模型。 + + model_name 显式传入时优先使用(如素材收集专家的检索词/排序专用快模型), + 否则回退到 configurable.model_name(用户在写作表单里选的主模型)。 + """ from deerflow.models import create_chat_model - model_name = config.get("configurable", {}).get("model_name", "") or None - return create_chat_model(name=model_name) + name = model_name or config.get("configurable", {}).get("model_name", "") or None + return create_chat_model(name=name) # 弱模型常把结构性标点写成中文全角符号,仅在常规解析失败后作为修复手段 @@ -220,11 +225,16 @@ class ThinkTagParser: return 0 -async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig) -> dict: - """调用 LLM,解析 JSON 输出,失败用 fallback。""" - model = get_model(config) +async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None) -> dict: + """调用 LLM,解析 JSON 输出,失败用 fallback。 + + model_name 可覆盖默认模型(如检索词/排序使用配置的专用快模型)。 + """ messages = [SystemMessage(content=system), HumanMessage(content=user)] try: + # get_model 放进 try:模型名在 config.yaml 找不到时 create_chat_model 会抛 + # ValueError,若留在 try 外会逐层上抛使整个图硬崩溃。 + model = get_model(config, model_name=model_name) resp = await model.ainvoke(messages) return parse_json(_content_to_text(resp.content), fallback) except Exception as e: 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 438dac98..6aa7019b 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 @@ -89,7 +89,19 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: ("language", LANGUAGE_REVIEW_USER.format(article=article)), ] - model = get_model(config) + # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, + # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 + try: + model = get_model(config) + except Exception as e: + logger.error("editor model creation failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": "编辑", + "title": "模型错误", "detail": str(e), "message": "审核失败", + }], + } results: dict[str, dict] = {} review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示 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 a575d902..92825d68 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 @@ -1,6 +1,8 @@ """意图解析节点:将用户自然语言意图结构化为写作配置。""" from __future__ import annotations +import re + from langchain_core.runnables import RunnableConfig from ..state import AIWritingState @@ -43,6 +45,33 @@ def _build_type_options(types: list[dict]) -> str: return " / ".join(f'{t["key"]}({t["label"]})' for t in types) +def _coerce_word_count(raw) -> int | None: + """稳健地把模型返回的字数字段转成正整数;无法解析时返回 None。 + + 弱模型(尤其老模型被陌生词带偏时)常返回「约800字」「两千」「800.0」 + 这类非纯数字形式,直接 int() 会抛 ValueError/TypeError 使整个图崩溃。 + 此处用「提取首个数字串」策略容错,解析不出就返回 None 让调用方保留默认值。 + """ + if raw is None: + return None + if isinstance(raw, bool): # bool 是 int 子类,需先排除 + return None + if isinstance(raw, (int, float)): + value = int(raw) + return value if value > 0 else None + if not isinstance(raw, str): + # dict / list 等结构化类型:str() 化后可能误含数字,直接判为无法解析 + return None + m = re.search(r"\d[\d,]*", raw) + if not m: + return None + try: + value = int(m.group().replace(",", "")) + except ValueError: + return None + return value if value > 0 else None + + async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict: # 从 configurable 读取动态类型列表,未配置时退回默认值 configurable = (config or {}).get("configurable", {}) @@ -73,10 +102,19 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d config=config, ) - updates: dict = { - "status": "researching", - "progress_events": [{"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}], - } + # 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底, + # 流程仍可继续,但用户需知道结构化配置可能不准)。 + progress_events: list[dict] = [] + if result.get("_llm_error"): + progress_events.append({ + "type": "notice", + "agent_name": "系统", + "title": "模型错误", + "detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}", + }) + progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) + + updates: dict = {"status": "researching", "progress_events": progress_events} # 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值 raw_type = result.get("article_type", "") @@ -84,8 +122,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d if result.get("target_audience"): updates["target_audience"] = result["target_audience"] - if result.get("word_count_target"): - updates["word_count_target"] = int(result["word_count_target"]) + word_count = _coerce_word_count(result.get("word_count_target")) + if word_count is not None: + updates["word_count_target"] = word_count if result.get("refined_intent"): updates["user_intent"] = result["refined_intent"] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index 3b8f19b4..688b9a02 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -40,6 +40,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict sse_queue = cfg.get("sse_queue") sse_session_id: str = cfg.get("sse_session_id", "") + # 素材收集专家专用模型:检索词生成 / 素材排序 可在 config.yaml 的 + # ai_writing 段单独配置(keyword_model / rank_model),留空则回退写作主模型。 + ai_writing_config = cfg.get("ai_writing_config", {}) or {} + keyword_model: str | None = ai_writing_config.get("keyword_model") or None + rank_model: str | None = ai_writing_config.get("rank_model") or None + async def _emit(message: str) -> None: if sse_queue is None: return @@ -59,13 +65,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict ), fallback={"keywords": [intent]}, config=config, + model_name=keyword_model, ) keywords: list[str] = kw_result.get("keywords", [intent]) if extra_keywords: keywords = list(dict.fromkeys(keywords + extra_keywords)) # Step 2: 并发搜索 - ai_writing_config = config.get("configurable", {}).get("ai_writing_config", {}) provider = get_search_provider(ai_writing_config) search_keywords = keywords[:6] @@ -136,6 +142,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"}, config=config, + model_name=rank_model, ) rank_error = rank_result.get("_llm_error", "") ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials]) 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 bf3da60f..14013dd1 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 @@ -73,7 +73,19 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di def _section_title(idx: int, sec: dict) -> str: return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}" - model = get_model(config) + # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, + # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 + try: + model = get_model(config) + except Exception as e: + logger.error("draft model creation failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": "作家", + "title": "模型错误", "detail": str(e), "message": "草稿生成失败", + }], + } section_titles: list[str] = [] section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 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 404f9d5d..bd3ad1e6 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 @@ -62,11 +62,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> sse_queue: asyncio.Queue | None = cfg.get("sse_queue") session_id: str = cfg.get("sse_session_id", "") - model = get_model(config) full_text = "" # 只累积正文(非思考)用于 JSON 解析 parser = ThinkTagParser() try: + # get_model 放进 try:模型名配错时 create_chat_model 抛 ValueError, + # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 + model = get_model(config) async for chunk in model.astream([ SystemMessage(content=OUTLINE_SYSTEM), HumanMessage(content=user_prompt), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py index 70b41eba..b8f7e576 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/researcher_prompts.py @@ -6,6 +6,11 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专 1. 从写作意图中提取 3-6 个搜索关键词 你不能写文章,不能评价内容好坏,只做关键词提取。 +【严禁思考】 +- 这是一个简单的结构化抽取任务,禁止任何思考、分析、推理或规划过程。 +- 禁止输出 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。 +- 收到输入后必须立即、直接输出最终 JSON,不得有任何前置内容。 + 【输出要求】 - 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。 - 必须使用英文双引号 ",不要用中文引号 “”。 @@ -31,6 +36,11 @@ RANK_MATERIALS_SYSTEM = """你是一名专业情报研究员(素材收集专 1. 从提供的素材列表中,选出与写作意图最相关的素材ID,按相关度从高到低排序 你不能写文章,只做素材筛选排序。 +【严禁思考】 +- 这是一个简单的筛选排序任务,禁止任何思考、分析、推理或规划过程。 +- 禁止输出 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。 +- 收到输入后必须立即、直接输出最终 JSON,不得有任何前置内容。 + 【输出要求】 - 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。 - 必须使用英文双引号 "。