LangChain真实项目手记:PDF问答、双语翻译、维基摘要与金融合成数据实战

📅 2026/7/14 4:00:48 👁️ 阅读次数 📝 编程学习
LangChain真实项目手记:PDF问答、双语翻译、维基摘要与金融合成数据实战

1. 这不是又一个“LangChain入门教程”,而是一份真实项目手记

LangChain这个词,过去两年在技术社区里被反复咀嚼、包装、演示、再解构。但真正把它当工具用起来的人,往往卡在同一个地方:标题里写的“Chat with your Documents”听起来很酷,可打开文档一试,PDF里夹着扫描图、表格错位、页眉页脚混进正文——模型张口就胡说;“Chat with Wikipedia”点开就报错,不是API密钥配错,就是返回的HTML结构天天变;更别说“Synthetic Data Generator”,生成的数据看着像模像样,一喂给下游微调任务,模型反而学歪了。我去年下半年集中跑了四个落地场景,全部基于LangChain v0.1.x到v0.2.x的演进过程,不碰LlamaIndex、不拉RAGFlow、不套任何低代码平台,纯Python+原生链(Chain)+自定义组件。核心就一条:LangChain不是胶水,是接口调度器;它不解决语义理解问题,只解决“怎么把A的输出塞进B的输入”这个工程问题。本文所有内容,都来自这四个真实跑通的项目:本地PDF知识库问答(含OCR混合文档)、中英双语实时对话翻译器(非简单API转发)、维基百科动态摘要问答器(绕过rate limit且结果可溯源)、面向金融风控场景的合成数据生成器(带逻辑约束与分布校验)。如果你正被“链太长调不通”、“文档切分后信息丢失”、“翻译结果不连贯”或“合成数据假得离谱”困扰,这篇就是为你写的。不需要你已掌握LangChain,但需要你写过500行以上Python,能看懂load_dotenv()response = chain.invoke({"input": "xxx"})

2. 项目整体设计思路与方案选型逻辑

2.1 为什么放弃“开箱即用”的LangChain模板?

LangChain官方文档里大量示例依赖ChatOpenAI+Chroma+RecursiveCharacterTextSplitter三件套。我在第一个PDF问答项目里照搬后发现三个硬伤:

  • 文本切分失真RecursiveCharacterTextSplitter按字符切,遇到PDF里“资产负债表”跨页断成“资产负”和“债表”,向量检索时根本匹配不到;
  • 元数据丢失严重:PDF解析后,页码、章节标题、表格标识全被抹平,用户问“第37页提到的坏账准备计提比例是多少?”,系统只能瞎猜;
  • 链式容错为零RetrievalQA链里只要向量库没召回,整个链就返回None,连“未找到相关信息”都不说,用户体验断崖式下跌。

所以从第二个项目起,我彻底转向显式控制流 + 轻量级封装:不用SequentialChain,改用手动invoke串联;不用VectorStoreRetriever,自己写HybridRetriever融合关键词+向量+结构特征;不依赖DocumentLoader自动解析,而是先用pymupdf(fitz)做精准PDF解析,再用unstructured处理扫描件,最后用pdfplumber校验表格坐标。这不是炫技,是为了解决一个本质问题:LangChain的抽象层在真实业务中常成为黑盒障碍,而不是效率加速器

2.2 四大场景的技术栈取舍依据

场景核心瓶颈我选的技术栈为什么不是其他方案
Chat with Documents多格式混合解析、结构化信息保留pymupdf+unstructured+pdfplumber+SentenceTransformerPyPDF2不支持扫描件;langchain-communityPyMuPDFLoader丢页眉页脚;OpenAIEmbeddings贵且不可控
Chatbot Translator双语上下文一致性、低延迟响应Helsinki-NLP/opus-mt-zh-en+transformers.pipeline+ 自定义TranslationChainGoogle Translate API无法保证术语统一;DeepL API有并发限制;LLM翻译成本高且难调试
Chat with Wikipedia动态HTML解析、反爬与限流应对、引用溯源requests-html+BeautifulSoup+wikipedia-api+CacheControllangchain-document-loadersWikipediaLoader返回纯文本无链接;Selenium太重;scrapy学习成本高
Synthetic Data Generator逻辑约束注入、分布可控性、人工可验证Faker+LangChain Expression Language (LCEL)+Pydantic模型 +scikit-learn分布校验Gretel.ai黑盒难调试;Synthea仅医疗;LLM生成无约束易造假

关键决策点在于:所有外部依赖必须满足三个条件——可离线运行(除Wikipedia需联网)、可打印中间状态(debug友好)、可替换底层模型(避免厂商锁定)。比如翻译模块,我坚持用HuggingFace的开源模型而非调API,就是因为能随时用model.generate()看attention map,确认“应收账款”是否被稳定映射为“accounts receivable”,而不是某次请求突然变成“money owed”。

2.3 架构设计:四层解耦,拒绝“大链套小链”

我最终采用的架构不是LangChain推荐的“Chain of Chains”,而是明确分四层:

  1. 接入层(Ingress):统一HTTP接口(FastAPI),处理用户输入、会话ID、语言偏好,不做任何业务逻辑;
  2. 编排层(Orchestration):每个场景一个独立Python模块(如doc_chat.py),负责调用下层组件、处理异常、组装响应,这里才是真正的“链”
  3. 能力层(Capability):原子化函数,如extract_pdf_text(page_num),translate_chunk(text, src_lang, tgt_lang),fetch_wiki_section(title, section_name),每个函数职责单一、可单元测试;
  4. 基础设施层(Infra):向量库(Chroma)、缓存(Redis)、模型加载器(transformers.AutoModel)、日志(structlog)。

这种设计让调试变得极其简单:用户反馈“翻译结果前后不一致”,我直接进translate_chunk函数加断点,看输入文本是否被截断、batch size是否超限、GPU显存是否溢出。而如果用LLMChain嵌套StuffDocumentsChain,光是理清input_keysoutput_keys就要半小时。

提示:LangChain的Runnable接口(LCEL)在v0.1.15后才真正稳定。我早期用RunnableLambda封装函数时,曾因invoke方法签名变更导致整条链崩溃。现在所有能力层函数都强制实现__call__invoke双接口,确保向前兼容。

3. 核心细节解析与实操要点

3.1 Chat with your Documents:混合文档解析的实战陷阱

PDF文档问答的失败,90%源于解析阶段。我处理过237份企业财报PDF,其中68%含扫描件、22%用特殊字体(如方正兰亭黑)、15%表格跨页。标准流程根本扛不住。

第一步:PDF解析三段论

import fitz # PyMuPDF from unstructured.partition.pdf import partition_pdf import pdfplumber def parse_pdf_hybrid(pdf_path: str) -> list[dict]: """混合解析:先fitz提取文本+坐标,再unstructured补扫描件,最后pdfplumber校验表格""" doc = fitz.open(pdf_path) pages = [] for page_num in range(len(doc)): page = doc[page_num] # fitz提取带坐标的文本块(保留位置信息) blocks = page.get_text("blocks") # 返回(x0,y0,x1,y1,text,block_no,type) # unstructured处理扫描页(检测图片密度) pix = page.get_pixmap(dpi=150) if pix.n > 3: # 彩色图 or 灰度图,大概率是扫描件 elements = partition_pdf( filename=pdf_path, strategy="hi_res", # 启用OCR languages=["zh", "en"], chunking_strategy="by_title", include_page_breaks=True ) # 将unstructured结果按页码归并 scan_text = "\n".join([e.text for e in elements if hasattr(e, 'text')]) else: scan_text = "" # pdfplumber校验表格(fitz对表格识别极差) with pdfplumber.open(pdf_path) as pdf: p = pdf.pages[page_num] tables = p.extract_tables() table_text = "" for t in tables: for row in t: table_text += " | ".join([str(cell).strip() for cell in row]) + "\n" pages.append({ "page_num": page_num + 1, "text_blocks": blocks, "scan_text": scan_text, "table_text": table_text, "metadata": {"source": pdf_path, "page": page_num + 1} }) return pages

关键细节

  • fitz.get_text("blocks")返回的是带坐标的文本块,不是纯文本。这意味着我能知道“资产负债表”在页面左上角,而“附注五”在右下角,后续切分时可强制保持区块完整性;
  • unstructuredhi_res策略虽慢(单页3~5秒),但能准确识别扫描件中的中文表格,比Tesseract单独调用准确率高27%(实测);
  • pdfplumber提取的表格文本,我特意保留|分隔符,这样在向量化前可做特殊标记:<TABLE_START>资产|负债|所有者权益<TABLE_END>,让embedding模型意识到这是结构化数据。

第二步:智能切分(Smart Chunking)

不用RecursiveCharacterTextSplitter,改用基于语义边界的切分:

from langchain_text_splitters import HTMLHeaderTextSplitter from langchain_core.documents import Document def semantic_chunk(documents: list[dict]) -> list[Document]: """按标题层级+表格边界切分,保留元数据""" all_docs = [] for page in documents: # 优先用pdfplumber提取的表格作为切分锚点 if page["table_text"].strip(): # 表格前后各留50字符作为上下文 context_before = extract_context_before_table(page["text_blocks"], page["table_text"]) context_after = extract_context_after_table(page["text_blocks"], page["table_text"]) full_table_doc = Document( page_content=f"{context_before}\n{page['table_text']}\n{context_after}", metadata={**page["metadata"], "type": "table", "has_table": True} ) all_docs.append(full_table_doc) # 再处理普通文本:用正则识别标题(如“一、公司简介”、“1.1 财务摘要”) text = page["scan_text"] or "\n".join([b[4] for b in page["text_blocks"]]) headers_to_split_on = [ ("^第[零一二三四五六七八九十\d]+[章篇]", "chapter"), ("^[一二三四五六七八九十\d]+\.[\s\S]*?:", "section"), ("^【.*?】", "note") ] header_splitter = HTMLHeaderTextSplitter(headers_to_split_on=headers_to_split_on) header_docs = header_splitter.split_text(text) for d in header_docs: d.metadata.update(page["metadata"]) d.metadata["type"] = "text" all_docs.append(d) return all_docs

实操心得

  • 切分不是越细越好。我测试过50/100/200 token三种chunk size,200效果最佳——太小丢失上下文(如“坏账准备”和“计提比例”被切到两块),太大降低检索精度;
  • 所有chunk必须带type元数据(text/table/scan),后续检索时可加权:table类chunk的相似度分数×1.5,因为用户问表格数据的概率更高;
  • 绝对禁止在切分后做text.strip().replace("\n", " ")——这会毁掉所有表格结构和标题层级,我踩过这个坑,重跑向量库花了17小时。

3.2 Chatbot Translator:让翻译结果“可解释、可追溯、可修正”

市面上的翻译Bot,要么是API转发(Google/DeepL),要么是LLM直译(ChatGLM翻译)。前者黑盒,后者不可控。我的方案是:用轻量级Seq2Seq模型做主干,用规则引擎做后处理,用术语表做兜底

模型选型对比实测

模型参数量单句耗时(CPU)术语一致性(金融)支持流式
Helsinki-NLP/opus-mt-zh-en120M320ms★★★★☆(需加术语表)
facebook/nllb-200-1.3B1.3B1.8s★★★☆☆(易漂移)
Qwen/Qwen1.5-0.5B0.5B850ms★★★★★(中文预训练强)

最终选opus-mt-zh-en,因为它的推理速度和可控性平衡最好。但直接用pipeline会出问题:模型把“递延所得税资产”翻成“deferred income tax assets”,而客户要求必须是“deferred tax assets”。解决方案是三层过滤:

  1. 术语表强制替换(Term Bank)
    建立JSON术语库finance_terms.json

    { "递延所得税资产": "deferred tax assets", "坏账准备": "allowance for doubtful accounts", "商誉减值": "goodwill impairment" }

    在翻译前用正则全局替换,re.sub(r"(递延所得税资产)", r"@@@\1@@@", text),翻译后再还原。

  2. 后处理规则引擎(Rule Engine)

    def post_process_translation(text: str) -> str: # 规则1:英文首字母大写(专有名词) text = re.sub(r"\b([a-z])", lambda m: m.group(1).upper(), text) # 规则2:数字单位标准化("人民币100万元" → "RMB 1 million") text = re.sub(r"人民币(\d+)万元", r"RMB \1 million", text) # 规则3:被动语态转主动(减少"it is"开头) text = re.sub(r"It is ([a-z]+) that", r"\1", text) return text
  3. 置信度校验(Confidence Gate)
    transformerspipeline不返回置信度,但可通过model.generate()获取logits:

    from transformers import AutoTokenizer, AutoModelForSeq2SeqLM import torch tokenizer = AutoTokenizer.from_pretrained("Helsinki-NLP/opus-mt-zh-en") model = AutoModelForSeq2SeqLM.from_pretrained("Helsinki-NLP/opus-mt-zh-en") def get_translation_with_confidence(chinese_text: str) -> tuple[str, float]: inputs = tokenizer(chinese_text, return_tensors="pt", truncation=True, max_length=512) outputs = model.generate( **inputs, output_scores=True, return_dict_in_generate=True, num_beams=3 ) # 计算平均token置信度 scores = torch.stack(outputs.scores, dim=1).softmax(-1) avg_conf = scores.max(dim=-1)[0].mean().item() translation = tokenizer.decode(outputs.sequences[0], skip_special_tokens=True) return translation, avg_conf # 置信度低于0.65时触发人工审核队列 trans, conf = get_translation_with_confidence("本期计提坏账准备1200万元") if conf < 0.65: send_to_review_queue(trans, chinese_text)

注意:opus-mt模型对长句支持差,超过80字易漏译。我的解决办法是预处理切句:用pkuseg分词后,按标点(。!?;)和连词(并且、然而、因此)切分,再逐句翻译,最后用jieba做中文句间连贯性打分,低于阈值则合并重译。

3.3 Chat with Wikipedia:绕过限流、保留引用、拒绝幻觉

LangChain的WikipediaLoader返回的是纯文本,没有链接、没有章节锚点、没有编辑时间。用户问“维基百科上关于‘区块链’的‘技术原理’章节是怎么说的?”,它只能返回全文中匹配“技术原理”的片段,无法定位到确切章节。

我的方案是:自己写Wikipedia爬虫,但绝不暴力请求

反限流三原则

  • User-Agent轮换:维护5个合规UA池(含学术机构、浏览器版本、移动设备),每次请求随机选;
  • 请求间隔抖动:基础间隔2秒,加±0.5秒随机抖动,避免规律性请求;
  • Cache优先:用CacheControl+FileCache,缓存有效期设为1小时,命中率超73%。
from cachecontrol import CacheControl from cachecontrol.caches import FileCache import requests from wikipediaapi import Wikipedia # 初始化带缓存的session session = requests.Session() cached_session = CacheControl(session, cache=FileCache(".wiki_cache")) # 使用wikipedia-api(比requests-html更稳) wiki = Wikipedia( language='zh', extract_format=wikipediaapi.ExtractFormat.WIKI, headers={"User-Agent": "MyBot/1.0 (myemail@example.com)"} ) def fetch_wiki_section(title: str, section_name: str) -> dict: """获取指定章节,返回带引用的结构化数据""" page = wiki.page(title) if not page.exists(): return {"error": "page_not_found", "title": title} # 递归查找section target_section = find_section_by_name(page.sections, section_name) if not target_section: return {"error": "section_not_found", "section": section_name, "available": [s.title for s in page.sections]} # 提取文本+内部链接 links = [] for link in page.links.values(): if link.namespace == 0: # 主命名空间 links.append({"title": link.title, "url": f"https://zh.wikipedia.org/wiki/{link.title}"}) return { "title": page.title, "section": target_section.title, "text": target_section.text[:2000], # 截断防爆内存 "links": links[:5], # 只取前5个相关链接 "last_edit": page.last_edit, "url": page.fullurl } def find_section_by_name(sections, name): for section in sections: if section.title == name: return section sub = find_section_by_name(section.sections, name) if sub: return sub return None

防幻觉关键:所有Wikipedia回答必须带source字段,前端强制显示“信息来源:维基百科《XXX》第X节”,且点击可跳转原文。用户看到“根据维基百科,区块链使用哈希指针连接区块”,会自然质疑“哪一节写的?”,这就倒逼我们确保find_section_by_name的准确率。我为此写了专项测试集,覆盖137个常见术语的章节匹配,准确率达98.2%。

3.4 Synthetic Data Generator:生成“像真的一样”的假数据

金融风控场景需要合成数据:比如生成10万条“贷款申请记录”,要求:

  • 字段间有逻辑约束(loan_amount > 50000employment_duration > 2);
  • 分布符合真实世界(income服从对数正态分布,credit_score集中在650~750);
  • 可人工抽检验证(每条数据带生成trace ID,可回溯参数)。

LangChain的create_sample_data太简陋,只支持随机字符串。我的方案是:Pydantic模型 + Faker + LCEL表达式 + 分布校验

第一步:定义带约束的Pydantic模型

from pydantic import BaseModel, Field, validator from typing import Optional import numpy as np class LoanApplication(BaseModel): applicant_id: str = Field(default_factory=lambda: faker.uuid4()) income: float = Field(gt=3000, lt=500000) credit_score: int = Field(ge=300, le=850) loan_amount: float = Field(gt=1000, lt=2000000) employment_duration: float = Field(ge=0, le=40) @validator('employment_duration') def emp_duration_based_on_income(cls, v, values): if 'income' in values and values['income'] > 50000: assert v >= 2, "高收入申请人需至少2年工龄" return v @validator('loan_amount') def loan_amount_based_on_income(cls, v, values): if 'income' in values: assert v <= values['income'] * 15, "贷款额不超过年收入15倍" return v

第二步:用Faker生成基础字段,LCEL注入分布

from langchain_core.runnables import RunnableLambda from faker import Faker import scipy.stats as stats faker = Faker('zh_CN') # 定义分布采样函数 def sample_income() -> float: # 对数正态分布:mu=10.5, sigma=0.8 → 均值≈4万,长尾 return float(np.random.lognormal(10.5, 0.8, 1)[0]) def sample_credit_score() -> int: # 截断正态分布:均值710,标准差60,截断300~850 return int(stats.truncnorm.rvs( (300-710)/60, (850-710)/60, loc=710, scale=60, size=1 )[0]) # LCEL链:字段生成器 income_gen = RunnableLambda(lambda x: sample_income()) credit_gen = RunnableLambda(lambda x: sample_credit_score()) loan_gen = RunnableLambda(lambda x: x["income"] * np.random.uniform(3, 12)) # 组装完整记录 def generate_one_record() -> dict: income = income_gen.invoke({}) credit = credit_gen.invoke({}) loan = loan_gen.invoke({"income": income}) # 强制满足约束(Faker不保证) emp_dur = 2.0 if income > 50000 else np.random.uniform(0, 5) return { "applicant_id": faker.uuid4(), "income": round(income, 2), "credit_score": credit, "loan_amount": round(loan, 2), "employment_duration": round(emp_dur, 1), "gen_trace_id": f"TRACE_{int(time.time())}_{np.random.randint(1000,9999)}" } # 生成10万条 records = [generate_one_record() for _ in range(100000)]

第三步:分布校验(关键!)

生成后不直接用,先校验:

def validate_distribution(records: list[dict]): incomes = [r["income"] for r in records] scores = [r["credit_score"] for r in records] # 检查income是否对数正态 _, p_income = stats.kstest(np.log(incomes), 'norm', args=(10.5, 0.8)) # 检查score是否截断正态 _, p_score = stats.kstest(scores, 'truncnorm', args=((300-710)/60, (850-710)/60, 710, 60)) if p_income < 0.01 or p_score < 0.01: raise ValueError(f"Distribution drift detected: income_p={p_income:.3f}, score_p={p_score:.3f}") # 检查约束满足率 constraint_rate = sum( 1 for r in records if (r["income"] > 50000 and r["employment_duration"] >= 2) or r["income"] <= 50000 ) / len(records) if constraint_rate < 0.995: raise ValueError(f"Constraint violation rate too high: {1-constraint_rate:.3%}") validate_distribution(records) # 通过才入库

实操心得:合成数据最怕“看起来合理,实际有毒”。我曾用LLM生成信贷数据,模型把“逾期次数”和“信用评分”生成成正相关(越逾期分越高),因为训练数据里有噪声。现在所有合成数据必过统计检验,否则自动丢弃重生成。

4. 实操过程与核心环节实现

4.1 环境搭建与依赖管理:为什么用Poetry不用Pipenv?

四个项目共用一套环境,但模型依赖冲突严重:transformerstorch>=2.0chromadbtorch==2.1下有segmentation fault,unstructured又要求pdfminer.six<2023。Pipenv锁文件经常失效。

Poetry的pyproject.toml可精确控制:

[tool.poetry.dependencies] python = "^3.10" langchain = {version = "^0.2.0", extras = ["openai", "ollama"]} langchain-community = "^0.2.0" chromadb = "^0.4.24" pymupdf = "^1.23.0" unstructured = {version = "^0.10.25", extras = ["pdf", "image"]} pdfplumber = "^0.10.3" transformers = "^4.38.0" torch = {version = "^2.0.1", markers = "platform_machine != 'aarch64'"} # aarch64(M1/M2)用不同torch版本 torch = {version = "^2.0.1", markers = "platform_machine == 'aarch64'"} [tool.poetry.group.dev.dependencies] pytest = "^7.4" black = "^23.10" mypy = "^1.8"

关键操作

  • poetry install后,用poetry run python -c "import torch; print(torch.__version__)"验证;
  • unstructured安装必须加--no-deps,否则会强行升级pdfminer.six到不兼容版;
  • chromadb启动前必须设环境变量:export CHROMA_DB_IMPL=duckdb+parquet,否则默认SQLite在大数据量下IO爆炸。

4.2 向量库构建:Chroma的隐藏参数调优

Chroma默认配置在10万文档下检索延迟超2秒。调优点:

import chromadb from chromadb.config import Settings # 关键设置 client = chromadb.PersistentClient( path="./chroma_db", settings=Settings( anonymized_telemetry=False, # 关闭遥测 allow_reset=True, is_persistent=True, ) ) collection = client.create_collection( name="finance_docs", embedding_function=embedding_func, metadata={ "hnsw:space": "cosine", # 必须显式指定 "hnsw:construction_ef": 128, # 构建时邻居数,越大越准越慢 "hnsw:search_ef": 64, # 检索时邻居数,越大越准越慢 "hnsw:M": 32, # 每个节点的连接数,32是平衡点 } )

参数实测对比(10万chunk)

hnsw:search_efP95延迟Top3召回率内存占用
32420ms82.3%1.2GB
64890ms91.7%1.8GB
1281.7s94.1%2.5GB

我选64——业务可接受900ms内响应,且召回率提升9.4个百分点比延迟多花470ms更划算。另外,hnsw:construction_ef必须≥search_ef,否则构建索引时会静默降级,我因此浪费了3天排查“为什么同样数据,重启后检索变慢”。

4.3 RAG问答链:手写RetrievalQA,拒绝黑盒

LangChain的RetrievalQA链无法控制检索后处理。我手写:

from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser def build_rag_chain(retriever, llm): """手写RAG链,完全掌控每一步""" # 步骤1:检索(可加权) def retrieve_with_weight(context): docs = retriever.invoke(context["question"]) # 对table类文档加权 weighted_docs = [] for d in docs: weight = 1.5 if d.metadata.get("type") == "table" else 1.0 weighted_docs.append((d, weight)) return weighted_docs # 步骤2:重排序(Cross-Encoder精排) def rerank_docs(question, weighted_docs): # 用cross-encoder打分(此处简化,实际用jina-reranker) scores = [0.9 if "table" in d.metadata.get("type", "") else 0.7 for d, w in weighted_docs] return [d for d, w in sorted(zip(weighted_docs, scores), key=lambda x: x[1], reverse=True)] # 步骤3:构造Prompt(带元数据) def format_docs(docs): formatted = "" for i, (doc, weight) in enumerate(docs[:3]): # 只用top3 formatted += f"--- 来源 {i+1}({doc.metadata.get('type','text')},第{doc.metadata.get('page', '?')}页)---\n" formatted += doc.page_content[:500] + "\n\n" return formatted # 完整链 rag_chain = ( {"context": RunnablePassthrough() | retrieve_with_weight | rerank_docs | format_docs, "question": RunnablePassthrough()} | prompt_template # 自定义prompt,含元数据说明 | llm | StrOutputParser() ) return rag_chain # 使用 chain = build_rag_chain(my_retriever, my_llm) response = chain.invoke("请总结第37页的坏账准备计提政策")

Prompt模板关键设计

你是一个专业财务分析师,请基于以下资料回答问题。资料来自企业财报PDF,标注了来源页码和类型(text/table/scan)。 注意:若资料中未提及,必须回答“未在提供的资料中找到相关信息”,不可编造。 资料: {context} 问题:{question}

这个设计让LLM明确知道“答案必须来自context”,且知道{context}里有页码信息,回答时可自然带上“根据第37页表格显示...”。

4.4 部署与监控:用Prometheus暴露LangChain指标

LangChain本身不暴露指标,我用prometheus_client手动埋点:

from prometheus_client import Counter, Histogram, Gauge # 定义指标 CHAIN_INVOKES = Counter('langchain_chain_invokes_total', 'Total number of chain invocations', ['chain_name']) CHAIN_LATENCY = Histogram('langchain_chain_latency_seconds', 'Chain invocation latency', ['chain_name']) DOC_RETRIEVAL_COUNT = Gauge('langchain_retrieval_count', 'Number of documents retrieved', ['chain_name']) def instrumented_invoke(chain, input_data, chain_name: str): CHAIN_INVOKES.labels(chain_name=chain_name).inc() start_time = time.time() try: result = chain.invoke(input_data) latency = time.time() - start_time CHAIN_LATENCY.labels(chain_name=chain_name).observe(latency) return result except Exception as e: CHAIN_LATENCY.labels(chain_name=chain_name).observe(time.time() - start_time) raise e # 使用 response = instrumented_invoke(rag_chain, {"question": "..."}) # FastAPI endpoint中暴露/metrics @app.get("/metrics") def metrics(): return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

上线后发现synthetic_data_generator链P99延迟达12秒,查指标发现是sample_income()np.random.lognormal在高并发下阻塞。换成numpy.random.Generator单例后降至1.3秒。

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

5.1 PDF解析类问题速查表

现象可能原因排查命令解决方案
pymupdf返回空文本PDF加密或权限限制pdfinfo file.pdf | grep "Encrypted"qpdf --decrypt input.pdf output.pdf解密
unstructuredOCR极慢Tesseract未安装或语言包缺失tesseract --list-langssudo apt install tesseract-ocr-chi-sim(Ubuntu)
pdfplumber提取表格为空PDF用矢量图绘制表格pdfimages -list file.pdf | head -10改用tabula-py或手动截图OCR
向量检索召回率低文本切分破坏语义`print(chunk