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+SentenceTransformer | PyPDF2不支持扫描件;langchain-community的PyMuPDFLoader丢页眉页脚;OpenAIEmbeddings贵且不可控 |
| Chatbot Translator | 双语上下文一致性、低延迟响应 | Helsinki-NLP/opus-mt-zh-en+transformers.pipeline+ 自定义TranslationChain | Google Translate API无法保证术语统一;DeepL API有并发限制;LLM翻译成本高且难调试 |
| Chat with Wikipedia | 动态HTML解析、反爬与限流应对、引用溯源 | requests-html+BeautifulSoup+wikipedia-api+CacheControl | langchain-document-loaders的WikipediaLoader返回纯文本无链接;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”,而是明确分四层:
- 接入层(Ingress):统一HTTP接口(FastAPI),处理用户输入、会话ID、语言偏好,不做任何业务逻辑;
- 编排层(Orchestration):每个场景一个独立Python模块(如
doc_chat.py),负责调用下层组件、处理异常、组装响应,这里才是真正的“链”; - 能力层(Capability):原子化函数,如
extract_pdf_text(page_num),translate_chunk(text, src_lang, tgt_lang),fetch_wiki_section(title, section_name),每个函数职责单一、可单元测试; - 基础设施层(Infra):向量库(Chroma)、缓存(Redis)、模型加载器(
transformers.AutoModel)、日志(structlog)。
这种设计让调试变得极其简单:用户反馈“翻译结果前后不一致”,我直接进translate_chunk函数加断点,看输入文本是否被截断、batch size是否超限、GPU显存是否溢出。而如果用LLMChain嵌套StuffDocumentsChain,光是理清input_keys和output_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")返回的是带坐标的文本块,不是纯文本。这意味着我能知道“资产负债表”在页面左上角,而“附注五”在右下角,后续切分时可强制保持区块完整性;unstructured的hi_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-en | 120M | 320ms | ★★★★☆(需加术语表) | 否 |
| facebook/nllb-200-1.3B | 1.3B | 1.8s | ★★★☆☆(易漂移) | 是 |
| Qwen/Qwen1.5-0.5B | 0.5B | 850ms | ★★★★★(中文预训练强) | 是 |
最终选opus-mt-zh-en,因为它的推理速度和可控性平衡最好。但直接用pipeline会出问题:模型把“递延所得税资产”翻成“deferred income tax assets”,而客户要求必须是“deferred tax assets”。解决方案是三层过滤:
术语表强制替换(Term Bank):
建立JSON术语库finance_terms.json:{ "递延所得税资产": "deferred tax assets", "坏账准备": "allowance for doubtful accounts", "商誉减值": "goodwill impairment" }在翻译前用正则全局替换,
re.sub(r"(递延所得税资产)", r"@@@\1@@@", text),翻译后再还原。后处理规则引擎(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置信度校验(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 > 50000→employment_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?
四个项目共用一套环境,但模型依赖冲突严重:transformers需torch>=2.0,chromadb在torch==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_ef | P95延迟 | Top3召回率 | 内存占用 |
|---|---|---|---|
| 32 | 420ms | 82.3% | 1.2GB |
| 64 | 890ms | 91.7% | 1.8GB |
| 128 | 1.7s | 94.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-langs | sudo apt install tesseract-ocr-chi-sim(Ubuntu) |
pdfplumber提取表格为空 | PDF用矢量图绘制表格 | pdfimages -list file.pdf | head -10 | 改用tabula-py或手动截图OCR |
| 向量检索召回率低 | 文本切分破坏语义 | `print(chunk |