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

环境这一步最不出成果,也最容易把人卡住一整天。 卡住的地方几乎不是「不会装」,而是装上了却跑不起来——版本对不上。 这一节把 pip 干的那件事拆开看:它其实是一个回溯求解器,在几十个版本里找一组互相不冲突的组合; 找不着,就报一段谁都读不懂的错。

📝 本章和前面几章的关系

这一节是全书唯一一节纯工程环境的内容,可以跳过去先跑 Demo,回头再看。 依赖与虚拟环境的一般原理,17 课 编程原理 讲过; 这一节讲的是大模型项目特有的那部分:两套轮子、几百兆的包、拆成十几个子包。

一、三条硬约束,先记住再动手

第一条,Python 版本。 LangChain 这类框架对版本有明确下限,老版本 Python 装不上新包, 新版本 Python 又可能还没有预编译轮子。 第二条,必须用虚拟环境。系统 Python 里装的东西,谁装的、什么时候装的、被谁升级过,都查不清。 项目一多,两个项目要的 numpy 版本不一样,就开始互相踩。 第三条,CPU 和 GPU 是两套轮子。 同样一个 torch,装 CPU 版和装 CUDA 版是两个不同的包,装错了不报错,只是跑起来用不上显卡。

这条最坑人:torch.cuda.is_available() 返回 False,你以为是驱动没装, 实际是当初 pip 装的是 CPU 版。装错和没装,表现一模一样。

二、虚拟环境:venv 与 conda 的取舍

venv 是 Python 自带的,一条命令建一个目录,激活之后 pip 装的东西全落在这个目录里。 够用、干净、没有额外依赖。 conda 多管一件事:它连 Python 解释器本身、以及 cuda 工具链这类非 Python 的库也能装。 代价是体积大、慢,而且 pip 和 conda 混用容易把依赖图搞乱。 经验上:只跑 Python 代码,用 venv;要在同一台机器上切不同 CUDA 版本,用 conda。

最忌讳的是「先 conda 装一半,再 pip 装一半」。 两套包管理器各记各的账,出问题时谁都不知道是谁覆盖了谁。 要混就定规矩:conda 只装 Python 和 cuda,其余一律 pip。

三、pip 到底在干什么:一次回溯求解

每个包声明的是区间,不是确定版本:pydantic 要 1.x 到 2.x,requests 要 2.x 到 3.x。 装的时候要把所有区间求交集,还要给每个包挑一个具体版本。 演示里五个包(七个版本条目)就能造出冲突:老的 langchain 要 pydantic 1.x 到 2.x, langchain-core 只要 pydantic 2.x。两个都要,就只能选 2.x。 求解的方式是回溯:先给第一个包挑个版本,往下推,推不下去就退回来换一个。 演示里解出那组解用了 5 次尝试;无解的那一组,1 次就撞死了。

这里有个必须说清的事实: 求解器给出的是「第一个能装上的组合」,不是「最好的组合」。演示里它先试 0.1.0,一试就成, 于是老版本压根没轮到。 所以「pip 装完能跑」只说明这组版本自洽,不说明它是最新、最稳、或者官方推荐的那一组。

四、依赖报错怎么读:只看两行

那段又长又吓人的报错,实际有用的只有两处:谁要什么、谁跟谁冲突了。 中间那一大堆「以下版本都被排除」,是求解器的过程记录。 看到冲突,处理顺序是固定的:先把报错里点名的那两个包的版本区间抄下来,看交集是不是空的。 空了就是真冲突,不空就是其中一个包被别的东西顶住了。 真正的解法只有三条:换一个能对上的版本、把顶住它的那个包降级、或者干脆换个不依赖它的方案。 没有第四条——不要靠 --force-reinstall 硬装。

硬装能装上,运行时才崩,而且崩在 import 或第一次调用的地方,离真正的原因隔了十万八千里。

五、0.1 之后拆包:import 路径会变

这一点是照着半年前教程装包时最常踩的坑。 早期的 LangChain 是一个大包,from langchain.prompts import ... 这样直接引。 后来它被拆成几层:核心抽象单独一个包,社区集成(各种向量库、各种加载器)单独一个包, 各家模型厂商再各出一个 partner 包。 拆包的好处是装得少、装得快——只接一个模型厂商,不用把几十家 SDK 全拖下来。 代价是老代码的 import 要改。

判断方法很简单:装完之后 pip list 看一眼装了什么。如果只有两三个包,说明是拆包之后的结构; 如果一个大包加一堆依赖,那是拆包之前。 遇到 ImportError 先去查这个包实际叫什么,别急着改代码——多半是路径变了,不是逻辑错了。

六、接本地 Llama 2:改两行配置

上一节说过模型这层的收益在「换得动」。 换到本地,靠的是接口兼容:推理服务把 Llama 2 包装成 OpenAI 那样的一问一答接口。 于是同一份业务代码,只要把 base_url 指向本机端口、api_key 随便填一个占位串, 就能从云 API 切到本地 7B。 这一步最容易漏的是 base_url 结尾:有的服务要带路径后缀,有的不要。 写错了不报连接错误,而是返回 404,报错信息还长得像「模型不存在」。

第二个容易漏的是超时。 本地 7B 在 CPU 上生成一段话要几十秒,默认超时往往等不到,表现成「请求失败」,其实是没等完。

七、动手 Demo:五个包的小依赖图,跑一次回溯求解

造一张只有五个包(七个版本条目)的依赖图,约束写成主版本区间。 版本号是代表值,只为演示约束关系,不代表任何真实发行版。 用三十行写一个回溯求解器,跑三种情况:能解、解出来是哪一组、以及解不出来时报什么。

PKGS = {
    u'langchain': {
        u'0.0.350': {u'pydantic': (1, 3), u'requests': (2, 3)},
        u'0.1.0':   {u'pydantic': (1, 3), u'langchain-core': (0, 1)},
    },
    u'langchain-core': {u'0.1.0': {u'pydantic': (2, 3), u'jsonpatch': (1, 2)}},
    u'pydantic': {u'1.10.13': {}, u'2.6.1': {}},
}

def solve(want):            # 回溯:推不下去就退回来换版本
    for ver in sorted(PKGS[name], reverse=True):
        lo = max(merged[k][0], r[0])
        hi = min(merged[k][1], r[1])
        if lo >= hi:        # 区间交空了
            continue

实跑输出:

--------------------------------------------------------------------
五个包的小依赖图,跑一次回溯求解
--------------------------------------------------------------------


  ① 依赖图
  --------------------------------------------------------------------
| 包 | 版本 | 依赖 |
|---|---|---|
| jsonpatch | 1.33 | 无 |
| langchain | 0.0.350 | pydantic(1, 3)、requests(2, 3) |
| langchain | 0.1.0 | pydantic(1, 3)、langchain-core(0, 1) |
| langchain-core | 0.1.0 | pydantic(2, 3)、jsonpatch(1, 2) |
| pydantic | 1.10.13 | 无 |
| pydantic | 2.6.1 | 无 |
| requests | 2.31.0 | 无 |

  约束写成「主版本区间」,比如 pydantic (1, 3) 就是 >=1 且 <3。真实 pip 的约束更细。
  ! 这张图小,但已经能造出冲突:langchain 0.0.350 要 pydantic 1.x 到 2.x,
  langchain-core 0.1.0 只要 pydantic 2.x。两个都装,只能选 2.x。

  ② 能解的情况
  --------------------------------------------------------------------
| 装上的包 | 版本 |
|---|---|
| jsonpatch | 1.33 |
| langchain | 0.1.0 |
| langchain-core | 0.1.0 |
| pydantic | 2.6.1 |

  回溯了 5 次,得到一组可行解。
  ! 这组解里没有 requests——只有老的 0.0.350 才依赖它,0.1.0 只要 langchain-core。
  ! 而之所以走到 0.1.0,是因为求解器按版本号倒序试,0.1.0 排在前头,一试就成,老版本压根没轮到。
  第一个可行解长什么样,全看候选怎么排序——所以 pip 装出来的组合未必是最新的,也未必是最稳的,
  只是最先撞上的那个。

  ③ 解不出来的情况
  --------------------------------------------------------------------
  要 pydantic 1.x,但同时要 langchain-core——它只支持 pydantic 2.x。尝试 1 次,无解。
  ┌─ pip 会这么报
  │ ERROR: Cannot install ... because these package versions
  │ have conflicting dependencies:
  │   pydantic 1.10.13 depends on ...
  │   langchain-core 0.1.0 depends on pydantic>=2.0
  └─
  ! 读这种报错,只看两行:「谁要什么」和「谁冲突了」。
  中间那一大堆「以下版本都被排除」是求解器的过程记录,不用看。

  ④ 版本对齐的三条经验
  --------------------------------------------------------------------
| 做法 | 理由 |
|---|---|
| 先装框架,再装插件 | 框架定版本,插件跟着走 |
| 锁文件进版本库 | requirements.txt 要写死版本号 |
| 一次只升一个包 | 一次升三个,出问题不知道是谁 |

  第二条最容易被跳过。不锁版本的 requirements,三个月后换台机器就装不上了。
  ! langchain 这类框架版本迭代很快,0.0.x 之间就会有破坏性改动。所以锁版本不是保守,是必需。

  ⑤ 真机上的命令
  --------------------------------------------------------------------

  [真机命令 · 本沙箱不执行]
  | pip install -r requirements.txt
  | pip check          # 检查已装包的依赖是否自洽
  | pip list --outdated
  要 pip 与真环境,本沙箱不执行;逻辑就是上面那个求解器。

①先把依赖图看清楚。 注意 langchain 有两个版本,它们要的 pydantic 区间不一样:老的 0.0.350 要 1.x 到 2.x(宽的), 新的 0.1.0 把它交给 langchain-core 去要 2.x(窄的)。 约束写成主版本区间是为了演示方便。 真实 pip 的约束细到小版本,所以真报错里的版本号会长得多,读法一样。 ②解出来的这一组里,langchain 是 0.1.0。为什么不是老的 0.0.350? 因为求解器按版本号倒序试,0.1.0 排在前头,一试就通,老版本压根没轮到。 这组解里也没有 requests——只有老的 0.0.350 才依赖它。换一个候选排序,装出来的包会完全不同。 所以「pip 装完能跑」只说明这组版本自洽,不说明它是最新、最稳、或者官方推荐的那一组。 ③无解那一格最有教学价值:要 pydantic 1.x,同时要 langchain-core,而它只认 pydantic 2.x。 求解器试了 1 次就撞死,说明冲突在第一个包上就暴露了。 注意它撞死得这么快:需求里 pydantic 已经是 1.x,langchain-core 一加进来就把上界压到 2 以下。 交集空了,没有版本可选,直接返回无解——不用往下试。 真 pip 这时会打一大段「以下版本都被排除」,那是求解器把每个候选版本的失败原因全列了一遍。 读法只看两行:谁要什么、谁跟谁冲突。中间那堆过程记录,一个字都不用看。 ④三条经验里,第二条最容易被跳过:把版本号写死进 requirements。 不锁版本的代价不是当天崩,是三个月后换台机器装不上——那时候你已经不记得当初装的是什么。 ⑤真机上的 pip check 比 pip freeze 有用:前者查已装包的依赖是否自洽,后者只是把列表导出来。 装完先跑一次 check,比跑起来再报错省事。

30 秒实验

  1. 把求解器的 reverse=True 去掉,改成按版本号正序试,重跑第 ② 步。 装上的 langchain 变成哪个版本?requests 会不会出现?
  2. 给 pydantic 加一个 3.0.0 版本(无依赖),重跑第 ② 步。求解器会不会选它?为什么?
  3. 在第 ③ 步的需求里把 pydantic 区间从 1.x 放宽到「1 到 3」,冲突消失了吗? 这说明修冲突的哪一条办法在起作用?

常见误区

  1. 在系统 Python 里直接 pip install。 装的时候一路顺,第二天另一个项目跑不起来,而且完全想不到是昨天那个包干的。
  2. 遇到冲突就 --force-reinstall。 装是装上了,运行时崩在 import,报错指向的是一个跟冲突毫无关系的地方。
  3. conda 装一半、pip 装一半,两套包管理器各记各的账。 查依赖树的时候,两边都显示「没问题」,合起来就是跑不起来。
  4. 照着半年前的教程抄 import。 0.1 之后包被拆过,路径变了,这时候该查的是「这个类现在在哪个包里」,不是改自己写的逻辑。
  5. base_url 少写或多写一段路径后缀。 不报连接失败,返回 404,而报错文本长得像「模型不存在」,于是开始怀疑模型名写错了。
自测三问(点开对答案) 1. 为什么说「pip 装完能跑」不等于「装对了」? —— 因为求解器只保证这组版本互相不冲突,取的是第一个能走通的组合。 候选的尝试顺序一变,装出来的可能是完全不同的一组——都「能跑」,但都不是你想要的那个。 2. 看到一大段依赖冲突报错,第一眼看哪里? —— 看「谁要什么」和「谁冲突了」这两处。剩下那一大堆是求解器逐个候选版本的失败记录,对你没用。 拿到那两个包的区间,先算交集是不是空的。 3. 为什么 CPU 版和 GPU 版的 torch 装错了不报警? —— 因为它们是两个名字不同、依赖不同的包,都能正常 import、正常算。差别只在有没有 CUDA 支持。 装错的唯一表现是「用不上显卡」,而报错信息长得跟「没装驱动」一模一样。

环境装好,最后一步是把这些东西拼成一个能答文档问题的系统, 并且说清楚它到底有多准——下一节算这笔账。

相关笔记