1. 项目概述:一个能自我迭代的新闻内容生成闭环系统

你有没有遇到过这样的场景:团队需要每天产出3~5篇高质量行业简报,但编辑人力有限,AI生成的内容又常常流于表面、缺乏深度、逻辑松散,甚至事实错误频出?我去年在给一家财经媒体做内容自动化支持时,就卡在这个死结上——用大模型直接生成文章,初稿看着还行,可一旦要用于正式发布,90%的内容都需要人工重写。直到我们把整个流程拆解成“新闻→摘要→正文→批评→优化”五个明确角色,并让每个角色都由独立的AI Agent承担,再用LangGraph编排它们之间的协作逻辑,才真正跑通了这条从原始信息到可发布内容的全自动流水线。这个“Publisher Agent”不是单个智能体,而是一个由5个专业化Agent构成的协同网络,它背后的核心思想是: 把人类编辑的工作流,原样翻译成AI可执行、可验证、可回溯的图结构任务链 。它不追求一步到位的完美输出,而是通过“生成-批判-修正”的循环机制,让内容质量在每次迭代中自然提升。关键词包括:LLM应用、AI Agent、LangChain、LangGraph、新闻摘要、内容生成、批判性反馈、迭代优化。如果你正在尝试用大模型做内容生产、知识整理或报告生成,又苦于结果不可控、不可信、不可复现,那这个Publisher Agent的设计思路和实操细节,就是你最该啃下的硬骨头。

2. 整体架构设计与核心思路拆解

2.1 为什么必须是图结构,而不是链式调用?

很多人第一次看到这个标题,第一反应是:“不就是用LangChain串几个Prompt,先摘要、再扩写、最后润色吗?”——这恰恰是踩进的第一个大坑。我试过用纯SequentialChain跑完全流程,结果非常惨烈:摘要环节漏掉关键数据,导致后续正文全部基于错误前提展开;批评环节指出“逻辑断裂”,但优化环节根本不知道该去改哪一句;更糟的是,一旦中间某步失败(比如新闻源格式异常),整个链条就断在那儿,连日志都找不到问题出在哪一层。LangGraph的价值,根本不在“多调用几次大模型”,而在于它强制你把每个Agent定义为 有明确输入契约、输出契约、失败兜底策略的独立服务单元 。我们最终的Publisher Agent图结构包含5个节点,但它们之间不是简单的A→B→C→D→E线性关系,而是:

  • NewsParser (新闻解析器)作为入口,只负责清洗原始HTML/JSON,提取标题、时间、来源、正文段落,输出结构化字典;
  • Summarizer (摘要生成器)接收NewsParser输出,但它的输入校验规则是:必须包含 "content_segments" 字段且长度≥3,否则触发重试逻辑,而不是直接抛错;
  • ArticleWriter (正文撰写器)的输入不仅来自Summarizer,还显式注入了用户指定的“目标读者画像”(如“面向CFO的技术决策者”),这个上下文变量在链式调用里极易丢失;
  • Critic (批评家)的输出不是自由文本,而是严格遵循JSON Schema的结构化反馈: {"issues": [{"type": "factual_inaccuracy", "location": "para_2", "evidence": "原文第3段称'Q2营收增长23%',但财报PDF第7页显示为18.7%'}]}
  • Improver (优化器)拿到这个结构化反馈后,只重写被标记的段落,其余部分原样保留,确保修改可追溯、可审计。

提示:LangGraph的State对象不是万能容器,而是你的“工作台”。我们定义的State Schema里,每个字段都有明确类型、必填标识和更新规则。比如 summary 字段是 str 类型且 required=True ,而 critique_feedback Optional[List[Dict]] 。这种强契约设计,让调试时一眼就能看出是哪个Agent输出了非法数据。

2.2 五个Agent的角色分工与能力边界

这五个Agent绝不是“换个名字的同一个大模型”,它们的Prompt工程、工具集成、输出约束完全不同,本质是五种专业角色的AI化身:

  • NewsParser :它不生成任何新内容,只做三件事——识别新闻发布时间(统一转为ISO 8601格式)、过滤广告/导航栏HTML标签、将长正文按语义切分为带序号的段落列表( [{"id": "para_1", "text": "..." }, ...] )。我们给它配了专门的HTML清洗工具( bs4 + 自定义CSS选择器),而不是依赖大模型理解DOM结构,因为后者在处理乱码页面时准确率暴跌至62%。

  • Summarizer :它的核心约束是“300字内覆盖5W1H”,Prompt里明确要求:“若原文未提及其发生地点,请标注‘[地点未说明]’,不得自行推测”。我们测试过100篇科技新闻,发现当强制要求标注缺失信息时,事实错误率从19%降到2.3%。这个细节看似微小,却是建立可信度的第一道防线。

  • ArticleWriter :它最特别的地方是“双模态输入”——既接收Summarizer的摘要,也接收用户上传的“风格指南”PDF(比如公司品牌手册)。我们用LangChain的 PyPDFLoader + RecursiveCharacterTextSplitter 预处理这份PDF,将其向量化后存入本地FAISS索引。ArticleWriter每次生成前,会先用摘要内容作为query检索最相关的3条品牌规范(如“技术术语必须使用英文原词,括号内附中文释义”),再把这些规范动态注入Prompt。这比把所有规范硬编码进Prompt有效得多,因为规范更新时只需替换PDF,无需改代码。

  • Critic :这是整个系统最“反常识”的设计。我们没让它泛泛而谈“这篇文章不够好”,而是限定它只能检查四类硬伤:(1)事实性错误(需引用原文位置和外部证据);(2)逻辑断层(如“A导致B”但未说明因果机制);(3)风格违规(如在正式报告中出现“咱们”“我觉得”等口语词);(4)信息冗余(同一观点在三个段落重复出现)。每类错误都配了具体判定标准和示例,比如“逻辑断层”的定义是:“存在结论性陈述,但前文未提供支撑性论据或数据,且该结论非常识性推断”。这种颗粒度,让批评意见真正可执行。

  • Improver :它不做全局重写,只做“外科手术式”修改。输入是ArticleWriter的原始输出+Critc的结构化反馈,输出是仅修改被标记段落的新版本。关键技巧在于:我们给Improver的Prompt加了一条铁律——“若Critc指出某段存在事实错误,请先用搜索引擎工具核实,确认错误后再修改;若无法核实,改为标注‘[需人工核查:XXX]’并保留原文”。这避免了AI“自信地编造答案”。

2.3 LangGraph状态机的关键设计取舍

LangGraph的状态流转不是自动的,每个节点的 run 方法都必须返回 {"next": [...]} 明确指定下一步。我们在设计时做了三个关键取舍:

第一, 拒绝“自动重试”幻觉 。很多教程教你在节点里写 while not success: try... except... ,但我们强制所有重试逻辑走图分支。比如Summarizer失败时,图结构会走到 summarize_retry 节点,该节点会先检查输入是否含乱码(用 chardet 检测编码),再决定是调用 encoding_fixer 工具还是直接报错。这样做的好处是:重试次数、失败原因、处理路径全部记录在State里,运维时一查日志就知道是编码问题还是模型超时。

第二, 状态快照必须轻量 。我们禁止在State里存原始HTML或PDF二进制数据,所有大文件都存本地路径(如 /tmp/news_abc123.html ),Agent只读取路径并实时加载。实测下来,State对象大小从平均42MB压到不足200KB,图执行速度提升3.8倍,内存溢出事故归零。

第三, 人工干预接口必须前置 。我们预留了 human_review 节点,当Critic给出高风险反馈(如涉及财务数据、法律条款)时,图会暂停并等待人工输入。这个节点不是摆设——它会自动生成一个Markdown格式的审查清单,包含原文段落、AI修改建议、风险等级(红/黄/绿)、以及一键复制到剪贴板的按钮。上线三个月,人工介入率从初期的37%降到5.2%,但关键错误拦截率保持100%。

3. 核心模块实现与关键技术细节

3.1 NewsParser:从混乱网页到结构化数据的清洗术

NewsParser的使命是“保真”,不是“美化”。我们见过太多AI解析器把新闻里的免责声明当成正文,或者把作者署名栏的“本文由AI辅助撰写”误判为内容主题。所以它的实现分三层:

第一层:鲁棒性HTML清洗
不用 requests.get().text 直接喂给大模型,而是先过一遍 BeautifulSoup 的防御性解析:

def clean_html(html_content: str) -> str:
    soup = BeautifulSoup(html_content, 'html.parser')
    # 移除所有script/style标签及其内容
    for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
        tag.decompose()
    # 保留仅含文本的p、div、article标签,但过滤掉class含'ad'、'banner'、'sidebar'的
    for tag in soup.find_all(['p', 'div', 'article']):
        if tag.get('class') and any(kw in ' '.join(tag['class']).lower() for kw in ['ad', 'banner', 'sidebar']):
            tag.decompose()
    return str(soup)

这段代码看起来简单,但解决了83%的广告污染问题。关键是 decompose() 而非 extract() ——前者彻底删除节点,后者只是移出DOM树但可能残留空白。

第二层:时间标准化引擎
新闻时间格式千奇百怪:“2024-04-15T14:30:00Z”、“4月15日下午2:30”、“昨天”、“上周末”。我们的方案是:先用正则粗筛( r'\d{4}-\d{2}-\d{2}.*?[\d:]+|[\u4e00-\u9fa5]+[今明昨]?[天周月年]|(\d+月\d+日).*?(\d+[:\u4e00-\u9fa5]+)' ),再交给 dateparser 库解析,最后强制转为UTC时区的ISO格式。对“昨天”这类相对时间,我们绑定新闻抓取时间戳作为基准,避免跨时区歧义。

第三层:语义段落切分
不用 \n\n 简单分割,而是训练了一个轻量级分类器(LogisticRegression + TF-IDF),判断相邻句子间是否属于同一语义单元。特征包括:标点符号密度(句号/感叹号占比)、人名/机构名共现频率、动词时态一致性。实测在财经新闻上,段落切分准确率达91.4%,远超纯规则方案的68%。切分后每个段落带唯一ID( para_1 , para_2 ...),为后续Critic精准定位打下基础。

注意:NewsParser的输出必须通过Pydantic V2严格校验。我们定义了 NewsInput 模型:

class NewsInput(BaseModel):
    title: str = Field(..., min_length=5, max_length=200)
    published_at: datetime = Field(...)
    source_url: HttpUrl
    content_segments: List[Segment] = Field(..., min_items=3)

其中 Segment 包含 id: str text: str 。任何不符合Schema的输出都会被LangGraph拦截,触发 InvalidStateError ,这比让错误数据流入下游导致雪崩式故障好一万倍。

3.2 Summarizer:在300字内榨干新闻价值的压缩算法

Summarizer的Prompt不是“请写一篇摘要”,而是像工程师写API文档一样精确:

你是一名资深财经记者,正在为《全球科技简报》撰写每日头条摘要。请严格遵守以下规则:
1. 字数上限:300汉字(不含标点),超限立即截断;
2. 必须覆盖:Who(主体)、What(事件)、When(时间)、Where(地点)、Why(原因)、How(方式);
3. 若原文未提及某要素,请写“[XX未说明]”,禁止推测;
4. 数字必须保留原文精度(原文写“约12亿”,不得简化为“12亿”);
5. 专有名词首次出现时标注英文原名(如“OpenAI(Open Artificial Intelligence)”);
6. 输出仅含摘要文本,无任何前缀(如“摘要:”)、无换行、无空格。

这个Prompt经过27轮AB测试优化。关键洞察是: 给AI设定“记者”身份比“AI助手”身份产出质量高41% ,因为身份暗示了专业责任;而“禁止推测”这条,直接把事实错误率从19%压到2.3%。

技术实现上,我们用LangChain的 ChatPromptTemplate 封装,并注入动态变量:

prompt = ChatPromptTemplate.from_messages([
    ("system", SYSTEM_PROMPT),
    ("human", "新闻标题:{title}\n发布时间:{published_at}\n原文段落:\n{content_segments_text}"),
])

其中 content_segments_text 是NewsParser输出的段落列表拼接,但做了特殊处理——每段开头加 [段落{idx}] 标记,方便后续Agent溯源。我们还加了 output_parser = StrOutputParser() ,确保输出是纯净字符串,避免大模型画蛇添足加JSON包裹。

实操心得:别迷信“温度值越低越准”。我们在测试中发现,对摘要任务, temperature=0.3 时事实准确率最高(89.7%), temperature=0 反而因过度保守漏掉关键动词。原因是:完全确定性的输出会回避所有需要推理的连接词(如“因此”“然而”),导致逻辑链断裂。

3.3 ArticleWriter:让AI读懂品牌手册的嵌入式知识注入

ArticleWriter的难点不在写作,而在“理解风格”。我们曾把公司品牌手册全文塞进Prompt,结果模型要么忽略,要么胡乱套用。真正的解法是: 把风格规范变成可检索的向量知识库

流程分三步:

  1. 预处理品牌手册 :用 PyPDFLoader 加载PDF, RecursiveCharacterTextSplitter chunk_size=300, chunk_overlap=50 切分,得到约120个文本块;
  2. 构建本地向量库 :用 HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") 生成嵌入,存入 FAISS 索引。选这个模型是因为它对中英混合文本支持好,且体积仅230MB,适合部署在4核8G服务器;
  3. 运行时动态注入 :ArticleWriter的Prompt模板里预留 {brand_guidelines} 占位符,实际调用时:
    # 用当前摘要内容作为query检索最相关3条规范
    query_embedding = embeddings.embed_query(summary_text)
    docs = vectorstore.similarity_search_by_vector(query_embedding, k=3)
    brand_guidelines = "\n".join([f"[规范{i+1}] {doc.page_content}" for i, doc in enumerate(docs)])
    

最终Prompt长这样:

你正在为[客户名称]撰写面向[目标读者]的行业分析文章。请严格遵循以下要求:
【品牌规范】
{brand_guidelines}
【待处理摘要】
{summary_text}
【输出要求】
- 首段必须用“近期,[主体]宣布...”句式开头;
- 所有技术术语首次出现时标注英文(如“大语言模型(Large Language Model, LLM)”);
- 禁止使用“我们”“笔者”等人称代词;
- 字数控制在800-1000字,分4-5个自然段;
- 输出仅含文章正文,无标题、无署名、无空行。

这个设计让风格一致性从人工审核的72%提升到自动达标率94.6%。最妙的是,当市场部下周更新品牌手册,我们只需替换PDF文件,所有Agent自动获得最新规范,零代码改动。

3.4 Critic:用结构化反馈终结“我觉得不好”的模糊批评

Critic是Publisher Agent的质检员,它的输出质量直接决定Improver能否有效工作。我们放弃自由文本反馈,强制采用JSON Schema:

{
  "issues": [
    {
      "type": "factual_inaccuracy",
      "location": "para_3",
      "original_text": "该公司Q2净利润同比增长45%",
      "evidence": "财报原文第12页:'Q2净利润为1.23亿元,去年同期为0.85亿元,同比增长44.7%'",
      "suggestion": "将'45%'改为'44.7%'"
    }
  ],
  "overall_score": 7.2,
  "summary": "事实准确,但第三段数据精度不足;逻辑清晰;风格符合品牌规范"
}

这个Schema的设计花了两周时间打磨。关键点在于:

  • location 字段必须精确到段落ID :这要求Critic的输入必须包含NewsParser生成的带ID段落,否则无法定位。我们甚至在Critic的Prompt里写死:“你只能使用 para_1 para_2 等ID,禁止使用‘第一段’‘上文’等模糊指代”;
  • evidence 必须可验证 :不允许“根据公开资料”这种话,必须给出具体出处(页码、URL锚点、表格编号)。为此,我们给Critic配备了 WebSearchTool ,但它只能搜索财报PDF、官网新闻稿等可信源,禁用通用搜索引擎;
  • suggestion 必须是原子操作 :只能是“替换X为Y”“删除Y”“在X后插入Z”,不能是“重写整段”。这确保Improver能无歧义执行。

技术实现上,我们用LangChain的 JsonOutputParser 配合Pydantic模型:

class CritiqueIssue(BaseModel):
    type: Literal["factual_inaccuracy", "logical_gap", "style_violation", "redundancy"]
    location: str
    original_text: str
    evidence: str
    suggestion: str

class CritiqueOutput(BaseModel):
    issues: List[CritiqueIssue]
    overall_score: float = Field(ge=0.0, le=10.0)
    summary: str

JsonOutputParser(pydantic_object=CritiqueOutput) 会自动校验输出,任何格式错误都会触发重试。实测中,Critic的结构化输出成功率从初始的61%提升到99.2%,靠的就是这个强约束。

3.5 Improver:外科手术式修改的精准执行引擎

Improver不做创作,只做修复。它的输入是ArticleWriter的完整输出(含段落ID)和Critic的结构化反馈,输出是仅修改被标记段落的新版本。核心逻辑是:

  1. 解析Critic反馈,提取待修改段落ID列表
  2. 用正则从ArticleWriter输出中提取这些段落的原始文本 (匹配 [段落para_X] 标记);
  3. 对每个待修改段落,构造专用Prompt
    你是一名专业编辑,正在修正以下段落。请严格按Critic建议操作:
    【原文】
    {original_text}
    【Critic建议】
    {suggestion}
    【约束】
    - 只修改被指出的问题,其余内容一字不动;
    - 修改后字数变化不超过±15字;
    - 保持原有段落ID标记(如[段落para_3]);
    - 输出仅含修改后文本,无解释、无前缀。
    
  4. map_reduce 模式并行调用大模型处理多个段落 ,最后按ID顺序拼接。

这个设计带来两个关键收益:一是修改可逆——原始段落存档,修改记录可查;二是性能可控——即使Critic标出10个问题,Improver也只调用10次小模型,而非重写全文。我们用 gpt-3.5-turbo-16k 做这一步,单次调用平均耗时1.2秒,比用 gpt-4-turbo 重写全文(平均8.7秒)快7倍。

注意:Improver有个隐藏规则——当Critic建议“删除某句”时,它不会真的删,而是替换为 [已删除:原句内容] 。这样做的目的是留痕:人工审核时一眼看出哪里被删,避免信息丢失。上线后,编辑团队反馈“这种标记比直接删除有用十倍”。

4. 完整工作流实现与实操配置

4.1 LangGraph图结构定义与节点注册

整个Publisher Agent的图结构定义在 publisher_graph.py 中,核心是 State 类和 nodes 字典:

from typing import Annotated, Dict, List, Optional, TypedDict
from langgraph.graph import StateGraph, END
from langchain_core.messages import AnyMessage
from pydantic import BaseModel, Field

class NewsState(TypedDict):
    # 输入字段
    raw_html: str
    user_profile: str  # 目标读者画像
    brand_guide_path: str  # 品牌手册路径
    
    # 中间产物
    parsed_news: Optional[dict] = None
    summary: Optional[str] = None
    article: Optional[str] = None
    critique: Optional[dict] = None
    
    # 控制字段
    retry_count: Annotated[int, Field(default=0)]
    human_review_needed: Annotated[bool, Field(default=False)]

# 节点函数定义(简化版)
def news_parser_node(state: NewsState) -> dict:
    try:
        parsed = NewsParser().parse(state["raw_html"])
        return {"parsed_news": parsed}
    except Exception as e:
        return {"retry_count": state["retry_count"] + 1}

def summarizer_node(state: NewsState) -> dict:
    if not state["parsed_news"]:
        return {"retry_count": state["retry_count"] + 1}
    summary = Summarizer().generate(
        title=state["parsed_news"]["title"],
        content_segments=state["parsed_news"]["content_segments"]
    )
    return {"summary": summary}

# 图构建
builder = StateGraph(NewsState)

# 注册节点
builder.add_node("news_parser", news_parser_node)
builder.add_node("summarizer", summarizer_node)
builder.add_node("article_writer", article_writer_node)
builder.add_node("critic", critic_node)
builder.add_node("improver", improver_node)
builder.add_node("human_review", human_review_node)

# 定义边(条件分支)
builder.add_conditional_edges(
    "news_parser",
    lambda x: "summarizer" if x["parsed_news"] else "news_parser_retry",
    {"summarizer": "summarizer", "news_parser_retry": "news_parser_retry"}
)

builder.add_conditional_edges(
    "critic",
    lambda x: "improver" if x["critique"]["overall_score"] < 8.0 else END,
    {"improver": "improver", END: END}
)

# 设置入口和出口
builder.set_entry_point("news_parser")
builder.set_finish_point(END)

# 编译图
publisher_graph = builder.compile()

这个定义的关键在于: 所有条件分支都基于State字段的显式值判断,而非隐式异常 。比如 critic 节点不抛异常,而是总返回 {"critique": {...}} ,让 add_conditional_edges 根据 overall_score 决定走向。这保证了图的可预测性——你知道每条路径的触发条件,而不是靠捕获异常来跳转。

4.2 环境配置与依赖管理

Publisher Agent对环境敏感度极高,我们用 pyproject.toml 锁定所有关键依赖:

[tool.poetry.dependencies]
python = "^3.10"
langchain = "^0.1.16"
langchain-community = "^0.0.35"
langgraph = "^0.0.39"
langchain-openai = "^0.1.4"
beautifulsoup4 = "^4.12.2"
faiss-cpu = "^1.8.0"
sentence-transformers = "^2.2.2"
PyPDF2 = "^3.0.1"
chardet = "^5.2.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
black = "^23.10.1"
mypy = "^1.7.0"

特别注意三点:

  • LangChain版本必须精确到补丁号 ^0.1.16 表示允许 0.1.16 0.1.99 ,但 0.2.0 会破坏API兼容性。我们线上用的就是 0.1.16 ,因为 0.1.17 引入的 RunnableConfig 变更导致Critic节点崩溃;
  • FAISS必须用CPU版 faiss-cpu 而非 faiss-gpu ,因为GPU版在Docker容器里常因CUDA版本冲突启动失败,而CPU版在4核机器上向量检索延迟<120ms,完全满足需求;
  • sentence-transformers 选MiniLM模型 paraphrase-multilingual-MiniLM-L12-v2 是目前开源模型中中英混合文本嵌入质量最高、体积最小的,加载耗时仅1.8秒,比 all-MiniLM-L6-v2 准确率高3.2个百分点。

环境变量配置放在 .env 文件:

OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
LANGCHAIN_TRACING_V2=true
LANGCHAIN_PROJECT=publisher-agent-prod
LANGCHAIN_ENDPOINT=https://api.smith.langchain.com

关键技巧: LANGCHAIN_TRACING_V2 开启后,所有节点调用、输入输出、耗时、token用量都会自动上报到LangSmith。我们靠这个功能发现了ArticleWriter的瓶颈——它87%的时间花在PDF向量检索上,于是针对性优化了FAISS索引的 nprobe 参数,延迟从320ms降到89ms。

4.3 本地测试与端到端验证脚本

光写代码不测试等于没写。我们写了三类测试脚本:

单元测试(test_nodes.py) :验证每个Agent的独立行为

def test_summarizer_handles_missing_who():
    input_data = {
        "title": "某公司发布新产品",
        "content_segments": [{"id": "para_1", "text": "该公司今日宣布推出AI助手。"}]
    }
    result = Summarizer().generate(**input_data)
    assert "[Who未说明]" in result
    assert len(result) <= 300

集成测试(test_graph.py) :验证图结构流转

def test_full_graph_with_valid_news():
    state = {
        "raw_html": "<html><body><h1>苹果发布Vision Pro</h1><p>2024年2月2日,苹果公司...</p></body></html>",
        "user_profile": "科技投资人",
        "brand_guide_path": "./tests/mock_brand.pdf"
    }
    result = publisher_graph.invoke(state)
    assert result["article"] is not None
    assert result["critique"]["overall_score"] >= 7.0

端到端验证(e2e_validation.py) :用真实新闻样本跑全流程,生成报告

def run_e2e_validation():
    samples = load_news_samples()  # 加载100篇真实新闻
    results = []
    for sample in samples:
        start_time = time.time()
        try:
            output = publisher_graph.invoke({
                "raw_html": sample["html"],
                "user_profile": sample["profile"],
                "brand_guide_path": sample["guide"]
            })
            duration = time.time() - start_time
            results.append({
                "sample_id": sample["id"],
                "status": "success",
                "duration": duration,
                "score": output["critique"]["overall_score"],
                "word_count": len(output["article"])
            })
        except Exception as e:
            results.append({"sample_id": sample["id"], "status": "error", "error": str(e)})
    
    # 生成Markdown报告
    report = generate_markdown_report(results)
    with open("e2e_report.md", "w") as f:
        f.write(report)

这个脚本每天凌晨2点自动运行,生成的报告包含:成功率、平均耗时、各节点失败率TOP3、Critique评分分布直方图。上线三个月,我们靠它把平均失败率从12.7%压到1.3%,关键改进是给NewsParser加了 chardet 编码检测。

4.4 生产部署与监控告警配置

Publisher Agent部署在Kubernetes集群,用 Deployment 管理,关键配置:

# publisher-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: publisher-agent
spec:
  replicas: 3
  selector:
    matchLabels:
      app: publisher-agent
  template:
    spec:
      containers:
      - name: publisher
        image: registry.example.com/publisher-agent:v2.7.3
        envFrom:
        - configMapRef:
            name: publisher-config
        resources:
          requests:
            memory: "2Gi"
            cpu: "1000m"
          limits:
            memory: "4Gi"
            cpu: "2000m"
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 5

监控告警用Prometheus+Grafana,核心指标:

指标名 描述 告警阈值 处理动作
publisher_graph_duration_seconds 图执行总耗时P95 > 45s 发送企业微信告警,自动扩容Pod
publisher_node_failures_total 各节点失败次数 critic 节点1小时内>5次 触发 critic_debug_mode ,记录详细日志
publisher_state_size_bytes State对象大小P95 > 500KB 强制清理临时文件,通知运维检查NewsParser

最关键的告警是 publisher_critic_score_distribution ——我们用Prometheus记录Critic评分的直方图。当 score < 6.0 的样本比例连续1小时超过15%,系统自动暂停新任务,发送邮件给内容负责人:“检测到低质量内容激增,疑似新闻源数据异常,请检查上游RSS Feed”。这个机制上线后,帮我们提前2小时发现了一次第三方新闻API的数据污染事故。

5. 常见问题与实战排查技巧

5.1 “Summarizer输出超300字”问题的根因与解法

这个问题出现频率最高,但90%的人只会调低 temperature 或加“请严格控制字数”提示。我们排查了237个失败案例,发现真正根因有三类:

第一类:新闻原文含大量列表项
原文:“产品特性包括:1. 语音识别;2. 实时翻译;3. 离线模式...”——大模型把每个数字项当独立句子计数,导致摘要膨胀。解法是在NewsParser里加预处理:用正则 r'\d+\.\s+[^\n]+' 识别列表项,合并为一句“产品特性包括语音识别、实时翻译和离线模式”。

第二类:时间表述引发冗余
原文:“会议将于2024年4月15日(星期一)下午2:30在总部大楼举行”。模型在摘要里重复“2024年4月15日”和“星期一”,其实只需保留ISO格式。解法是在Summarizer Prompt末尾加硬约束:“日期只允许出现一次,格式为YYYY-MM-DD”。

第三类:模型对“字数”概念理解偏差
中文里“字”指汉字字符,但大模型常把标点、空格、英文单词全算进去。我们实测发现,当Prompt写“300字”时,模型输出平均328字符;写“300汉字”时,平均297汉字+31标点=328字符。终极解法是:Summarizer输出后,用Python脚本二次截断:

def truncate_to_chinese_chars(text: str, max_chars: int) -> str:
    chinese_count = 0
    for i, char in enumerate(text):
        if '\u4e00' <= char <= '\u9fff':  # Unicode中文范围
            chinese_count += 1
        if chinese_count > max_chars:
            return text[:i]
    return text

这个函数确保无论模型怎么输出,最终摘要严格≤300汉字。上线后,超限率从31%降到0%。

5.2 “Critic找不到事实错误”但人工审核发现严重错误

这是信任危机的高发场景。我们复盘了17次此类事故,发现根本问题不在Critic,而在 NewsParser的保真度不足 。典型案例如下:

  • 原文 :“据路透社报道,该公司2023年营收为12.3亿美元(约合87.6亿元人民币)”
  • NewsParser输出 {"content_segments": [{"text": "该公司2023年营收为12.3亿美元"}]} —— 丢掉了括号内的人民币换算
  • Critic检查 :用12.3亿美元搜索财报,发现一致,判定“无事实错误”
  • 人工审核 :发现人民币换算错误(实际应为88.2亿元),但Critic根本没见过这个数据

解法是强化NewsParser的“信息完整性校验”:

def validate_content_integrity(segments: List[dict]) -> bool:
    # 检查是否含货币换

更多推荐