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

这一节把框架拆成八件零件:模型、提示、输出解析器、索引、内存、链、代理、工具。 每件给一个最小实现,都真跑; 然后把前四件串成一条链,最后看清代理与链的分界——步骤写死的是链,步骤运行时才定的是代理。

📝 本章和前面几章的关系

上一节说了「为什么要这层胶水」,这一节看胶水里到底有几件东西。 索引与检索的细账在 6.4 节还会再算一次; 多轮对话里内存该带什么,第 4 章整章都在讲,这一节只讲它在框架里叫什么。

一、八件零件的地图

一段话把八件串起来:把资料切成块放进索引,用户提问时先从索引里检索; 把检索到的资料、内存里的槽位、当前问题一起填进提示模板;交给模型; 拿回来的文字用解析器变成结构化结果。 这几步按固定顺序串起来,就是链。需要算数、查库这类确定性动作,交给工具。 如果「该不该用工具、用哪个」本身要判断,就上代理。 八件里只有模型是必须的,其余七件都可以不要——这句话听着像废话,但它决定了你加零件时的判断: 加一件,得说得出它解决哪个具体问题。

二、模型、提示、解析器:入口、模子、出口

模型这层的承诺只有一句:给一段文字,返回一段文字。 演示里那个规则模型回了 39 token,这个数本身说明它也在数字——真模型的计费单位就是它。 抽象的收益在这一层的构造里:接云 API 要 base_url 和 api_key,接本地 vLLM 也要这两个,只是值不同。 所以同一份业务代码,改两行配置就能从云换到本地。 Ollama 也提供 OpenAI 兼容接口,道理一样。这不是巧合,是行业默认形成了统一形状。

提示这层是两个东西合起来:一个模板字符串,一张变量表。 演示里的模板有四个变量——role、slots、ctx、q。 四个里有两个是多轮问答特有的:ctx 装检索到的资料,slots 装从历史里抽出的槽位。 单轮问答只要 role 和 q 两个,所以单轮系统看着简单,一上多轮就复杂。 解析器是出口。模型答「防护等级是 IP65,接线盒同等」,解析器要的是那个 'IP65'。

它还有第二层作用,容易被忽略:它是格式对不对的唯一判据。 解析成功率掉下来,说明提示里的格式约束没生效,先去改提示,别急着换模型。

三、索引与内存:外面的知识和里面的上下文

索引有两端。写入端负责切块、算向量、存起来;读取端负责把问题也变成向量,按相似度取前 k 块。 演示里只做了读取端,用的是两字重合度这个笨办法。 笨办法的坑在第二名:相似度 0.00 的那块也被取进来了。因为 k 是 2,取不满也要凑。 为什么是 0.00? 问题里是「防护等级要多少」,块里是「灯具防护等级户外不低于 IP65」,按两字一组切, 只有「防护」这一组能对上,其余全不重合,一归一化就接近零。

真机必须换成向量。不然排序基本靠字面撞运气,用户换个说法就检索不到。 token 账也在这:全塞进去 138 token,只取前两块 73 token,省了将近一半。 但如果省下来的是「该有的那块」, 省的就是负收益——检索层的指标不是「省了多少」,是「该在的还在不在」。 内存这层最容易被误解成「把历史存下来」。存是存储的事,内存这层真正决定的是下一轮要带什么进去。

演示里记了 3 条消息、两个槽位:部位已确认、预算已确认。 槽位跟原文的差别在于长度和稳定性:原文「部位是东立面」六个字,换成「东立面那边」这五个字, 槽位里还是同一个值。

四、链与工具:顺序和确定性

链这层就是顺序。演示里一条链四个动作:检索、拼模板、调模型、解析。 顺序里有一条不能反:先检索再拼模板。 反过来,资料根本进不了提示,检索白做,而且不报错——模型照样答,答得还挺像。 工具这层的判据只有一句:能写成函数的,都不该让模型想。 演示里的工具数一段文本的 token 数,喂进刚才那条 prompt,得 93。

为什么不能让模型自己数?因为它按分词器切,而分词器不是它的一部分。它只能猜,猜错也不报错。 工具的描述比工具本身更重要。 真机上模型就是读那段 description 来决定用不用——描述写得含糊,等于这个工具不存在。

五、代理:只在步骤数不确定时才用

链和代理的分界不在复杂度,在步骤数什么时候定。写代码时就定死的是链; 运行时看着情况走几步的,是代理。 演示里的路由表三个问题:问 token 数的选中了工具,问防护等级和问预算的都没用工具。 这里用关键词匹配,是最笨的路由。 真机上是把每个工具的 description 摊给模型,让它自己选。看着聪明,代价是每次决策多一次模型调用。

多绕一圈的代价具体是什么:延迟加一倍,出错概率也加一倍。 代理自己的选择会错,选错的工具比不用工具更糟——它会拿一个不对的函数去算一个真问题, 然后给出一个带小数点的错答案。 所以判断很干脆:步骤固定的任务,一条链跑完,别上代理。 查资料要查几轮才够、该不该换个说法再查一次——这种才值得让代理决定。

六、动手 Demo:八件零件,逐个最小实现,再串成一条链

八个类,每个不到二十行,全部真跑。 模型是规则函数,索引是字面重合度,工具是真函数,代理是关键词路由。 重点看三处:解析器取到了什么、检索的第二名为什么是 0.00、代理在哪些问题上选择了不用工具。

class Model:
    name = u'rule-llm'
    def __call__(self, prompt):
        return u'结论:准备报验单、隐蔽记录、通电测试'

class Index:
    def search(self, q, k=2):
        return sorted(self.chunks, key=lambda c: -overlap(q, c))[:k]

class Chain:
    def run(self, q):
        ctx = self.index.search(q)     # 先检索
        p = self.prompt.format(ctx=ctx, q=q)   # 再拼模板
        return self.parser(self.model(p))

实跑输出:

--------------------------------------------------------------------
八件零件,逐个最小实现,再串成一条链
--------------------------------------------------------------------


  ① Model:一切的地基
  --------------------------------------------------------------------
  Model 就是「给一段文字,返回一段文字」。name 是 rule-llm。
  输出 39 token。
  ! 这层抽象的意义:换模型只改这一处。
  真机上它的构造函数里有 base_url、api_key、timeout,外面调用的代码一行都不用动。

  ② Prompt:模板 + 变量
  --------------------------------------------------------------------
  四个变量:role、slots、ctx、q。漏一个 format 就会抛 KeyError。
  ! 提示模板要能装下检索到的资料和记忆里的槽位——这两样正是多轮问答跟单轮的区别。

  ③ Output Parser:把文本变结构
  --------------------------------------------------------------------
  解析结果:'IP65'
  ! 解析器还有一层作用:它是「格式对不对」的唯一判据。
  解析失败率高,说明提示里的格式约束没生效,不用去看模型。

  ④ Index:切块 + 检索
  --------------------------------------------------------------------
| 相似度 | 块 |
|---:|---|
| 0.18 | 验收分两步:通电测试记录电流与压降,再核对报… |
| 0.00 | 灯具防护等级户外不低于 IP65,接线盒同等… |

  全塞进去 138 token,取前两块 73 token。
  ! 注意第二名的相似度是 0.00 却也被取进来了——两字重合度算不出语义相近,真机必须换成向量,
  否则排序基本靠字面撞运气。

  ⑤ Memory:最近几轮 + 槽位
  --------------------------------------------------------------------
  记了 3 条,槽位:部位=已确认;预算=已确认
  ! Memory 不是「把历史全存下来」,是决定「下一轮要带什么进去」。这个决定比存多少更重要。

  ⑥ Chain:把上面四件串起来
  --------------------------------------------------------------------
  一条链跑完:检索 → 拼模板 → 调模型 → 解析。结论 = 准备报验单、隐蔽记录、通电测试
  ! 注意顺序:先检索再拼模板。反过来的话,资料进不了提示,检索白做。

  ⑦ Tool:能算的事别让模型猜
  --------------------------------------------------------------------
  工具:count_tokens(数一段文本的 token 数)。把刚才那条 prompt 喂进去,得 93。
  ! 工具的意义是确定性。模型算 1234 乘 5678 会错,函数不会。
  凡是能写成函数的,都不该让模型「想一想」。

  ⑧ Agent:决定用不用工具、用哪个
  --------------------------------------------------------------------
| 问题 | 选中工具 |
|---|---|
| 这段 prompt 有多少 token | count_tokens |
| 防护等级要多少 | (不用工具) |
| 帮我算一下预算 | (不用工具) |

  路由规则是关键词匹配,真机上是让模型读每个工具的 description 自己决定。
  ! Agent 的坑在于「多绕一圈」。
  能一条链跑完的,不要上 Agent——多一次模型调用就多一次出错和延迟的机会。

①模型这层最好理解,也最没有争议:文字进、文字出。 输出 39 token,说明连「规则函数」都得占 token 位——这正是计费单位。 换模型只改这一处构造。这句话听着平淡,但它是整套抽象唯一真正的收益。 ②提示那层,四个变量漏一个就 KeyError。这是好事,错误在拼的时候暴露,不用等生成完再猜。 ③解析器从一整句里只捞出 'IP65'。 它是整条链上唯一能判「格式对不对」的地方——解析成功率掉了,先改提示,别怪模型。 ④检索这一格要盯住第二名:相似度 0.00,还是被取进来了。因为 k 等于 2,取不满也得凑数。 这就是字面相似度的极限:换个说法就归零。真机换向量,才能从「字面撞上」变成「意思相近」。 token 账:全塞 138,取前两块 73。 省下来的那 65 个 token 里,有一块可能是噪声——省 token 不是目的,别把该有的那块省掉才是。 ⑤内存记了 3 条消息、两个槽位。 注意它记的不是原文,是「部位已确认、预算已确认」这种状态——存原文是存储,抽状态才是内存。 ⑥链条跑完的顺序值得再看一眼:检索在最前。反过来资料进不了提示,检索白做,而且整条链不报错。 ⑦工具数出 93 token。这个数模型自己算不出来,因为它不掌握分词器。凡是能写成函数的,都别让模型想。 ⑧代理那张表最有意思的是后两行:问防护等级、问预算,都没有选工具。 不是所有问题都该调工具,能直接答的直接答。 最笨的错误是「为了显得智能,什么问题都先过一遍工具」。多一次调用就多一次出错机会。 能一条链跑完的,不要上代理。

30 秒实验

  1. 把检索的 k 从 2 改成 1,重跑第 ④ 步。第二名那块被扔掉之后,整条链的结论变了吗? 如果没变,说明什么?
  2. 给内存加第三个槽位「期限」。第 ⑤ 步的输出会变成什么样?这个槽位该从哪句话里抽?
  3. 把代理的关键词路由改成「先问模型该不该用工具」。在演示这三个问题上,它会多绕几次? 哪些情况下会选错?

常见误区

  1. 以为内存就是把聊天记录全存下来。存下来谁都会,难的是决定下一轮带哪几条进去。 全带的代价是 token 线性涨,而准确率并不跟着涨。
  2. 把八件零件全都装上。 八件里只有模型是必须的,其余每一件都在解决一个具体问题——说不出它解决哪个问题,就别装。
  3. 索引用了字面重合度还当成能上线的方案。 演示里第二名相似度 0.00 就是警告:用户换个说法,你要的块根本排不进前 k。
  4. 链里把「调模型」写在「检索」前面。 不报错、不崩,只是资料永远进不了提示,答出来全靠模型自己编——这类错最像「模型能力不行」。
  5. 该用链的地方上了代理。 步骤固定的任务让代理每次重新决定,延迟翻倍、出错翻倍,换来的灵活性一次都用不上。
自测三问(点开对答案) 1. 检索的第二名相似度是 0.00,为什么还是被取进提示里了? —— 因为取的是「前 k 块」,k 等于 2,候选不够也得凑满。这是工程上的默认行为,不是 bug。 真机上要设一个相似度下限,低于阈值就返回「没找到」,而不是硬塞一块噪声进去。 2. 为什么说「槽位比原文稳」? —— 原文的说法每次都不一样,「部位是东立面」「东立面那边」指的是同一个东西,字面却不同; 抽成槽位之后,值是同一个字符串,长度也短。指代、换说法、省略,都影响不到它。 3. 链和代理的分界线是什么? —— 步骤数是什么时候定下来的。写代码时就定死,用链;要看着中间结果决定下一步走几步,才用代理。 复杂度不是分界,不确定性才是。

零件认全了,下一节装环境:Python 版本、依赖冲突、锁版本,还有那个看起来无害、 实际能把人卡一整天的 pip 报错。

相关笔记