异常处理

This commit is contained in:
董宏凯 committed 2026-05-18 22:41:44 +08:00
1 parent 17b28f43f8
commit b25c4e33cb
7 files changed
+108 -16

No files matched your search

@@ -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:
@@ -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] = [] # 分项审查的模型异常,供完成后非致命提示
@@ -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"]
@@ -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])
@@ -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] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -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),
@@ -6,6 +6,11 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专
1. 从写作意图中提取 3-6 个搜索关键词
你不能写文章,不能评价内容好坏,只做关键词提取。
【严禁思考】
- 这是一个简单的结构化抽取任务,禁止任何思考、分析、推理或规划过程。
- 禁止输出 <think></think> 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。
- 收到输入后必须立即、直接输出最终 JSON,不得有任何前置内容。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 ",不要用中文引号 “”。
@@ -31,6 +36,11 @@ RANK_MATERIALS_SYSTEM = """你是一名专业情报研究员(素材收集专
1. 从提供的素材列表中,选出与写作意图最相关的素材ID,按相关度从高到低排序
你不能写文章,只做素材筛选排序。
【严禁思考】
- 这是一个简单的筛选排序任务,禁止任何思考、分析、推理或规划过程。
- 禁止输出 <think></think> 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。
- 收到输入后必须立即、直接输出最终 JSON,不得有任何前置内容。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。