From d2c76100f2bf1a7ab66a14af90010390f68a6e11 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 24 Jun 2026 16:13:08 +0800 Subject: [PATCH] =?UTF-8?q?perf(ai=E5=86=99=E4=BD=9C):=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=87=AA=E5=B8=A6=E5=A4=A7=E7=BA=B2=E6=95=B4=E7=90=86?= =?UTF-8?q?=E6=8F=90=E9=80=9F=E5=B9=B6=E6=B5=81=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户自带大纲时「整理大纲」原有两个阻塞式非流式 LLM 调用,内网模型 关不掉导致用户长时间干等: - 素材关联(每次都调)改为确定性词重叠 _assign_materials_heuristic, 零 LLM、瞬时;有编号/标题/缩进的大纲整理几乎零等待。 - 散文化大纲的 LLM 归一改为流式 _stream_normalize_user_outline: ThinkTagParser 把思考推思考框、正文实时打字机展示并累积解析; 并在 OUTLINE_NORMALIZE_USER 末尾追加 /no_think。 - researcher 路径只做确定性解析,解析不出返回 None,交给 writer_outline 流式归一(outline_chunk 在该阶段天然映射大纲思考框)。 - 移除废弃的 OUTLINE_MATERIAL_ASSIGN_* 提示词与 llm_json 死引用。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents/ai_writing/nodes/writer_outline.py | 195 ++++++++++++------ .../ai_writing/prompts/writer_prompts.py | 29 +-- 2 files changed, 140 insertions(+), 84 deletions(-) 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 7b78f3d2..ad59d9a5 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 @@ -3,14 +3,13 @@ from __future__ import annotations import json import logging +import re from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from ..outline_parser import parse_user_outline from ..prompts.writer_prompts import ( - OUTLINE_MATERIAL_ASSIGN_SYSTEM, - OUTLINE_MATERIAL_ASSIGN_USER, OUTLINE_NORMALIZE_SYSTEM, OUTLINE_NORMALIZE_USER, OUTLINE_REVISE_SYSTEM, @@ -35,7 +34,7 @@ from ._model_fallback import ( emit_model_switch_event, fallback_chain, ) -from ._utils import ThinkTagParser, _content_to_text, llm_json, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer +from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -143,12 +142,81 @@ def _derive_title(state: AIWritingState) -> str: return intent[:30].rstrip() + ("…" if len(intent) > 30 else "") -async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline: - """确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。 +# ── 素材关联:确定性词重叠(替代 LLM,零延迟)──────────────────────────────────── +_ALNUM_TOKEN_RE = re.compile(r"[a-z0-9]+") +_CJK_CHAR_RE = re.compile(r"[一-鿿]") - 再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。 + +def _text_tokens(text: str) -> set[str]: + """把文本切成可比较的 token 集合:CJK 取相邻 bigram、英文/数字取整词(均小写)。 + + bigram 让「新能源汽车出口」这类短标题与素材标题/正文做稳定的词重叠匹配, + 无需任何模型调用。 + """ + if not text: + return set() + text = text.lower() + tokens: set[str] = {w for w in _ALNUM_TOKEN_RE.findall(text) if len(w) >= 2} + cjk = _CJK_CHAR_RE.findall(text) + tokens.update(cjk[i] + cjk[i + 1] for i in range(len(cjk) - 1)) + return tokens + + +def _section_query_text(section: dict) -> str: + """章节用于匹配素材的文本:章节标题 + 各小节标题 + 子要点,越全匹配越准。""" + parts = [section.get("section_title") or ""] + for kp in section.get("key_points", []) or []: + if isinstance(kp, dict): + parts.append(kp.get("point_title") or "") + parts.extend(s for s in (kp.get("sub_points") or []) if isinstance(s, str)) + elif isinstance(kp, str): + parts.append(kp) + return " ".join(p for p in parts if p) + + +def _assign_materials_heuristic(outline: Outline, pkg: dict) -> Outline: + """确定性地把素材按词重叠归类到各章节(替代 LLM 调用,零延迟、零 等待)。 + + 每条素材分配给与其标题/正文词重叠最高的章节(必须有重叠才分配);与所有章节都不 + 重叠的素材不强行塞入(writer_draft 自带「取前 2 条素材」兜底)。只改 material_ids, + 绝不动标题与层级。 + """ + sections = outline.get("sections", []) + materials = pkg.get("materials") or [] + if not sections or not materials: + return outline + + section_tokens = [_text_tokens(_section_query_text(s)) for s in sections] + assigned: list[list[str]] = [[] for _ in sections] + for m in materials: + mid = m.get("id") + if not mid: + continue + mat_tok = _text_tokens(f"{m.get('title', '')} {m.get('content', '')}") + if not mat_tok: + continue + best_idx, best_score = -1, 0 + for i, sec_tok in enumerate(section_tokens): + score = len(mat_tok & sec_tok) + if score > best_score: + best_idx, best_score = i, score + if best_idx >= 0: + assigned[best_idx].append(mid) + + for section, ids in zip(sections, assigned): + section["material_ids"] = ids + return outline + + +async def _stream_normalize_user_outline( + state: AIWritingState, config: RunnableConfig, raw: str, writer +) -> Outline: + """散文化大纲:确定性解析失败时流式调 LLM 归一成三级结构,实时展示给前端。 + + 内网模型 关不掉 → 用 ThinkTagParser 把思考与正文分流:思考推 is_thinking + 思考框、正文累积后解析 JSON,用户全程看得到进度而非干等。再失败则把每个非空行当作 + 一个章节兜底,保证永远产出可用大纲、不丢用户内容。 """ - model_name = (state.get("model_name") or "").strip() or None line_fallback = { "title": "", "sections": [ @@ -156,75 +224,84 @@ async def _normalize_user_outline_via_llm(state: AIWritingState, config: Runnabl for ln in raw.splitlines() if ln.strip() ] or [{"section_title": "正文", "key_points": [], "material_ids": []}], } - result = await llm_json( - OUTLINE_NORMALIZE_SYSTEM, - OUTLINE_NORMALIZE_USER.format(raw_outline=raw), - line_fallback, - config, - model_name=model_name, - ) - return _parse_outline(result) + name, _ = resolve_writing_model_name(config, state) + full_text = "" + try: + from deerflow.models import create_chat_model + + model = create_chat_model(name=name) + parser = ThinkTagParser() + async for chunk in model.astream([ + SystemMessage(content=OUTLINE_NORMALIZE_SYSTEM), + HumanMessage(content=OUTLINE_NORMALIZE_USER.format(raw_outline=raw)), + ]): + 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 + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking}) + except Exception as e: + logger.error("stream normalize user outline failed: %s", e, exc_info=True) + return _parse_outline(line_fallback) + return _parse_outline(parse_json(full_text, line_fallback)) -async def _assign_materials_to_outline( - state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict -) -> Outline: - """为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。 +async def build_user_outline_with_materials( + state: AIWritingState, config: RunnableConfig, pkg: dict +) -> Outline | None: + """搜集素材阶段调用:用户自带大纲时,解析其大纲并把已检索素材归类到各章节。 - LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则 - 保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。 + 复用本模块的解析/归类逻辑,让 researcher 能在「搜集素材」阶段顺便整理素材, + 从而**跳过 writer_outline 的大纲生成步**(见 graph 路由)。无用户大纲返回 None。 + 已有结构化大纲(如 re_search 二次检索)则复用结构、仅重新归类素材,不重复解析。 + + 只做**确定性**解析与素材关联(零 LLM、瞬时):散文化、解析不出层级的大纲返回 + None —— 不在 researcher 阶段阻塞式调 LLM 归一,交给 writer_outline 流式归一并实时 + 展示(见 graph after_material_pause 路由)。 """ - sections = outline.get("sections", []) - materials = pkg.get("materials") or [] - if not sections or not materials: - return outline - - valid_ids = {m["id"] for m in materials} - sections_list = "\n".join( - f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections) - ) - model_name = (state.get("model_name") or "").strip() or None - result = await llm_json( - OUTLINE_MATERIAL_ASSIGN_SYSTEM, - OUTLINE_MATERIAL_ASSIGN_USER.format( - user_intent=state.get("user_intent", ""), - sections_list=sections_list, - materials_summary=_materials_summary(pkg), - ), - {"assignments": []}, - config, - model_name=model_name, - ) - - assignments = result.get("assignments") - if not isinstance(assignments, list): - return outline - for i, section in enumerate(sections): - if i >= len(assignments): - break - ids = assignments[i] - if isinstance(ids, list): - section["material_ids"] = [mid for mid in ids if mid in valid_ids] - return outline + raw = (state.get("user_outline_raw") or "").strip() + if not raw: + return None + existing = state.get("current_outline") + if existing and existing.get("sections"): + outline: Outline = existing + else: + outline = parse_user_outline(raw) + if outline is None: + return None + if not outline.get("title"): + outline = {**outline, "title": _derive_title(state)} + return _assign_materials_heuristic(outline, pkg) async def _build_outline_from_user( state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str ) -> dict: - """用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。""" + """用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。 + + 可确定性解析的大纲(带编号/标题/缩进)零 LLM、瞬时完成;只有散文化、解析不出层级 + 的大纲才流式调一次 LLM 归一(实时展示思考/正文,应对内网模型关不掉的 ), + 素材关联一律走确定性词重叠。 + """ writer = safe_get_stream_writer() - writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True}) outline = parse_user_outline(raw) if outline is None: - # 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一 - outline = await _normalize_user_outline_via_llm(state, config, raw) + # 确定性解析拿不准(散文化/格式混乱)→ 流式 LLM 兜底归一,全程实时展示 + outline = await _stream_normalize_user_outline(state, config, raw, writer) + else: + writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True}) if not outline.get("title"): outline = {**outline, "title": _derive_title(state)} - # 仍走了检索 → 把已检索素材关联到用户章节(结构锁定) - outline = await _assign_materials_to_outline(state, config, outline, pkg) + # 仍走了检索 → 把已检索素材关联到用户章节(确定性词重叠,零 LLM) + outline = _assign_materials_heuristic(outline, pkg) section_count = len(outline.get("sections", [])) return { diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py index ff392b73..a50aaa07 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py @@ -149,33 +149,12 @@ OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。 OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文: {raw_outline} -请逐条保留原文,仅判断层级,输出结构化大纲 JSON。""" +请逐条保留原文,仅判断层级,输出结构化大纲 JSON。 -# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)────────────────────────── +/no_think""" -OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。 -给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节 -挑选最相关的素材编号。 - -【规则】 -- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。 -- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。 -- material_id 必须来自给定素材清单里出现过的编号,不要编造。 -- 一条素材可以分配给多个章节,也可以一个都不分配。 - -【输出要求】 -- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。 -- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。""" - -OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent} - -文章大纲(章节按顺序编号): -{sections_list} - -可用素材: -{materials_summary} - -请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。""" +# 注:用户自带大纲的「素材关联」已改为确定性词重叠(writer_outline._assign_materials_heuristic), +# 零 LLM、零 等待,原 OUTLINE_MATERIAL_ASSIGN_* 提示词已废弃移除。 DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。 你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。