🦙 第6章 Llama 2 + LangChain 文档问答模型构建
📄 一句话概括

LangChain 自己不含一个权重。 它做的只有一件事:把「拼提示、调模型、取结果」这三段拆成可以单独替换的零件。 这一节先手搓三十行把一条链跑通,看清框架到底替你做了什么; 再说清代价——什么时候加这层抽象是赚的,什么时候是纯亏。

📝 本章和前面几章的关系

前五章都在动模型:部署、微调、对齐。这一章不动模型,只在它外面加一层编排。 18 课 Agent+RPA 讲的是「谁来决定下一步做什么」, 这一节讲的是把这些决定落地成代码时,那一层胶水长什么样。

一、它不生产模型,只做粘合

真实的应用里,模型从来不是一个。开发时用云 API,上线换成本地 Llama 2;这里用 7B,那里用 70B; 有人走 vLLM,有人走 Ollama。 如果业务代码里直接写死「怎么调这个模型」,换一次模型就要把所有调用点改一遍, 而且改完不知道有没有漏。 LangChain 解决的就是这件事。 它给模型套一个统一的接口,再把周边的东西各做成一个零件:模型、提示、输出解析器、索引、内存、链、 代理、工具。

教材 6.2 节逐个演示的就是这八件。 八件里只有第一件是模型,剩下七件全是围着模型转的脚手架——这句话是理解这套框架的起点。 所以它的价值不在「功能多」,在「换得动」。不打算换,这层抽象就是纯成本。

二、手搓一条链:三件事混在一个函数里

不用框架,一条最小的链三十行就够:拼一段字符串,把它交给模型,再从返回的文本里切出想要的字段。 能跑,代码也短。但它把三件事压在同一个函数里:拼提示、调模型、取结果。 任何一件要改,都得动这个函数,改完三件一起重测。 第一个隐患是变量漏填。演示里模板少一个 role,格式化的时候当场抛 KeyError,一眼就知道缺什么; 而字符串拼接漏了同一个变量,整条链路一声不响,模型收到一个从没见过的花括号占位符,要么照抄, 要么乱猜。

这种错最难查:没有异常、没有日志、输出还是一段通顺的中文。 只有人拿眼睛看,才发现答案里带着「{role}」四个字符。 第二个隐患是取结果。 演示里三种答案形态——全角冒号、半角冒号、中间还夹着理由——手写的 split 只吃下第一种。 全角半角一混,取到的是空字符串;格式稍微一变,把理由整段吞进结论里。 而换成一条正则,三种都取到「可以改」。

解析器存在的理由就是这个:把「答案长什么样」收进一个地方。模型换了、模板改了,只改解析器, 下游取字段的代码一行不用动。

三、抽象的代价:三条要认

第一条,报错栈变深。chain.invoke 抛出来的异常,栈里七八层都是框架代码,真正的原因埋在中间一行。 手搓版出错,栈就三层,一眼看到底。 第二条,版本迭代快。0.0.x 之间就有破坏性改动; 0.1 之后整个包被拆成langchain-core、langchain-community 和各家的 partner 包,照着半年前的教程装, import 就会失败。 第三条,token 消耗被藏起来。链里每一步都能悄悄往提示里塞东西,不看代码根本不知道实际发出去多少字。 这一条在按 token 计费时最疼。

三条代价加起来,判断标准其实很清楚:只有一段提示、一个模型、一次调用——别用; 同样的编排要在三处以上重复、模型要换来换去——用。

四、那条竖线:为什么零件比函数好

LangChain 后来的写法是把零件用竖线串起来:提示、模型、解析器,中间各一个竖线。 读起来就是数据从左流到右。 和手写函数的差别不在写法,在每一步都是一个独立对象。 可以单独拿一段假数据喂给解析器做测试,不必先跑模型。 这一点在调试时最值钱:答案不对,先单独跑解析器,能过就说明问题在模型或提示上,不用整条链一起怀疑。

顺带还白拿两个能力:批量跑和流式输出。手写要实现这两个,得自己起线程、自己管缓冲。

五、动手 Demo:三十行拼一条链:模板 → 模型 → 解析器

这份 Demo 不用 LangChain,纯手写。 所有「模型」都是一个规则函数:按问题里的关键词返回固定格式的答案。 目的是把三段职责摆到台面上:哪一段出错会安静地错,哪一段出错会当场报。 五个小节:先朴素拼、再模板化、再上解析器、跑三种输入、最后对照真机写法。

TPL = u'你是{role}。\n问题:{q}\n请按「结论:… 理由:…」两段作答。'

def naive(q):                    # 拼接版
    return u'你是亮化工程助理。\n问题:' + q + u'\n请按……作答。'

def ask(prompt):                 # 假装是模型
    if u'改' in prompt:
        return u'结论:可以改。 理由:东立面独立回路,不动主配电。'

def parse(a):                    # 解析器:手写 split
    return a.split(u':')[1].strip()

print(parse(ask(naive(u'东立面能改吗'))))

实跑输出:

--------------------------------------------------------------------
三十行拼一条链:模板 → 模型 → 解析器
--------------------------------------------------------------------


  ① 直接拼字符串:先跑通
  --------------------------------------------------------------------
  ┌─ 拼出来的 prompt
  │ 你是亮化工程助理。
  │ 问题:东立面能改吗
  │ 请按「结论:… 理由:…」两段作答。
  └─
  这就是最朴素的做法:字符串拼接。能跑,代码也短。
  ! 但它把三件事混在了一起:拼提示、调模型、取结果。混在一起之后,任何一件要改,都得动同一个函数。

  ② 模板化:变量漏填会当场报错
  --------------------------------------------------------------------
| 情况 | 什么时候发现 | 看到什么 |
|---|---|---|
| 模板缺 role | 拼模板时当场报 | KeyError: 'role' |
| 拼接漏填 | 不报错,一路到生成 | {role} 留在提示里 |

  模板化最大的好处不是好看,是错误提前:变量漏了,拼的时候就知道,不用等模型答完。
  ! 拼接版的漏填会一路走到生成:模型看到一个从没见过的 {role},要么照抄,要么乱猜。
  这种错最难查,因为整条链路都没报错。

  ③ 解析器:三种答案形态都能吃下
  --------------------------------------------------------------------
| 答案形态 | 手写 split 取到 | 正则取到 |
|---|---|---|
| 结论:可以改。 理由:东… | 可以改。 | 可以改 |
| 结论:可以改。理由:东立… | 没有全角冒号,取不到 | 可以改 |
| 结论:可以改。理由:东立… | 把理由也吞进来了 | 可以改 |

  三种答案里,手写 split(u':') 只能吃下一种——全角半角一混就废,换行一多就串。
  ! 这就是解析器存在的理由:把「答案长什么样」这件事收进一个地方。
  模型换了、模板改了,只改解析器,下游不用动。

  ④ 三种输入跑一遍
  --------------------------------------------------------------------
| 问题 | 结论 | 理由 |
|---|---|---|
| 东立面能改吗 | 可以改 | 东立面独立回路,不动主配电 |
| 预算够不够 | 二十万够 | 两百平以内 |
| 这个怎么弄 | 需要现场确认 | 信息不足 |

  三条都拿到了结构化结果。下游代码取 d['结论'],不用再关心答案里有没有换行。
  ! 这就是「链」的最小形态:模板负责拼,模型负责答,解析器负责收。
  LangChain 做的事,就是把这三段各自做成可替换的零件。

  ⑤ 真机上的对照
  --------------------------------------------------------------------

  [真机代码 · 本沙箱不执行]
  | from langchain.prompts import ChatPromptTemplate
  | from langchain.schema import StrOutputParser
  | 
  | chain = ChatPromptTemplate.from_template(tpl) | llm | StrOutputParser()
  | out = chain.invoke({'role': '工程助理', 'q': q})
  要 langchain 与一个真模型,本沙箱都没有,不执行。

  那条竖线把三段串起来,跟上面那个 chain() 函数是一个意思。
  差别在于它是可替换的零件,而上面那个是写死的代码。
  ! 什么时候不该用 LangChain?只有一段提示、一个模型、一次调用的时候。加一层抽象只会让报错更难读。
  三处以上再上框架。

①先看最朴素的写法。它就是字符串拼接,能跑,三十行不到。 问题不在长短,在三件事压在一个函数里:拼、调、取。 ②这一格是这一节最该记住的对照:同一个「变量漏填」,两种写法的表现完全不同。 模板版:拼的时候就抛 KeyError,还告诉你缺的是哪个变量。 拼接版:不报错,{role} 原样留在提示里,一路送到模型跟前。 模型看到花括号会怎么办?要么当成要填的空照抄出来,要么猜一个填进去。两种都错,而且都不报错。 ③解析器那一栏,三种答案形态对应三行。 手写 split 只吃下第一种:全角冒号能找到,但把「理由」整段也吞进了结论。 半角冒号那行更惨:找不到全角冒号,split 返回只有一段,取下标就崩,这里写成「取不到」。 到正则那一栏,三行全过。 这就是为什么需要解析器: 答案的形态是模型给的,不由你控制。你能控制的只有「怎么从各种形态里把字段捞出来」这一层。 ④三种输入跑一遍,三条都拿到了结构化结果,下游直接取 d['结论']。 注意第三条「这个怎么弄」——信息不足,模型答的是「需要现场确认」。 这种答案以前会被当成失败,现在它是正常输出: 模型该说不知道的时候说不知道,比编一个更像答案的东西强。 ⑤最后那段真机代码,那一串竖线把三段串起来,语义上跟上面那个三十行的函数一模一样, 只是每段都成了可替换的零件。 什么时候不该用 LangChain?只有一段提示、一个模型、一次调用的时候。加一层抽象只会让报错更难读。 三处以上重复同样的编排,再上框架。

30 秒实验

  1. 把 Demo ① 的朴素拼接改成「先拼再调再切」三段独立函数,不改任何数据。重跑,输出一样吗? 哪一步出问题时,定位速度快了多少?
  2. 给解析器再加一种形态:答案写成两行,第一行只有「结论:可以改」,第二行「理由:…」。 手写 split 还能吃下吗?正则要改哪一处?
  3. 把模板里的 role 改成可省——漏填时用默认值「助理」而不是报错。这个「方便」会带来什么问题?

常见误区

  1. 以为 LangChain 是个模型。它不含权重、不会推理、不能离线答任何问题。 把幻觉归因于「LangChain 不准」,是把胶水当成了发动机。
  2. 一上来就上框架。 一行调用能解决的事,套三层抽象,出问题先怀疑自己写错还是框架版本不对, 排查时间比重新写一遍还长。
  3. 用字符串拼接拼提示,还觉得「反正是个 f-string」。 变量一多,漏填、重名、转义全都不报错, 最后表现成「模型答得怪」——实际是提示里塞了半个花括号。
  4. 把解析器的失败当成模型笨。解析失败率高,先看提示里的格式约束写没写清; 解析器是最后一个能拦住脏数据的地方,不是甩锅的地方。
自测三问(点开对答案) 1. 为什么模板化能让「变量漏填」这个错误提前暴露? —— 因为格式化是一步显式的替换动作,替换前要检查变量表,缺了就抛 KeyError。 字符串拼接只是把几段字粘起来,粘的时候没有任何检查点,缺的东西要等到生成的文本里才看得出来。 2. 链里最该被单独测试的是哪一段? —— 解析器。它不依赖模型、不依赖网络,喂一段固定文本就能测;而它一坏,整条链的输出全错。 **先测最便宜、最确定的那一段。** 3. 什么情况下加 LangChain 这层抽象是纯亏的? —— 一段提示、一个模型、一次调用,之后再也不改。 这时候抽象不解决任何「换」的问题,只增加了报错栈深度和一次版本升级的风险。

下面一节把八件零件逐个拆开,每件都给一个最小实现,再串成一条完整的链。

相关笔记