大模型工程实战:API 调用、本地部署与进阶学习地图

中等 🟡AI 学习
12 个标签
预计阅读时间:76 分钟
API调用Ollama模型量化本地部署RAG实战学习路径function callingvLLM向量数据库成本优化重排序GGUF

大模型工程实战:API 调用、本地部署与进阶学习地图

理解了大模型的原理、学会了写提示词之后,下一步就是"动手把它跑起来"。本文是一份偏工程实践的教程,带你完整走一遍从调用云端 API、到本地部署开源模型、再到搭建一个真正能跑的 RAG 应用的全过程,最后给出一份继续深入学习 AI 的路线图。读完本文,你应该能独立完成"调用一个大模型 API""在自己电脑上跑一个开源模型""搭一个真正能回答自己文档问题的私人知识库"这几件事,并且知道每一步在真实生产环境里还需要补齐哪些工程细节。

本文的组织方式是:每一个知识点都遵循「先定义概念 → 讲清为什么重要 → 拆解背后的原理 → 给出可运行代码 → 结合真实案例 → 用数据和对比表格量化 → 列常见坑 → 总结最佳实践」的结构。你可以顺着读,也可以把它当成一份速查手册,需要哪块跳到哪块。

🚀 第一部分:调用云端 API

1.1 概念:什么是大模型 API

大模型 API(Application Programming Interface,应用程序编程接口)指的是模型厂商把训练好的大模型部署在自己的服务器集群上,对外暴露一组标准化的网络接口。你不需要拥有任何 GPU,只要通过 HTTP 请求把"消息"发过去,服务器就会把模型生成的"回答"返回给你,然后按照你消耗的 token 数量计费。

换句话说,API 把"拥有并运行一个几百亿参数的模型"这件极其昂贵的事情,变成了"按次付费打一个网络请求"这么简单。这也是绝大多数人接触大模型工程的第一站。

1.2 为什么先从 API 入手

对初学者来说,先学 API 调用而不是一上来就本地部署,有几个现实原因:

零硬件门槛:不需要显卡,一台能上网的普通电脑就能调用世界上最强的模型。
效果天花板高:云端 API 提供的通常是厂商最强、最新的旗舰模型,效果远超个人能在本地跑起来的开源模型。
反馈快:几行代码就能看到结果,能快速建立对"输入什么、输出什么"的直觉。
迁移成本低:主流厂商的接口设计高度相似(大多兼容 OpenAI 的 messages 格式),学会一家,切换到另一家或切换到本地部署都只是改几个参数的事。

1.3 申请与配置 API Key

无论是 OpenAI、Anthropic 还是国内的模型厂商,调用云端大模型的第一步都是注册账号并申请一个 API Key(一串用于身份验证和计费的密钥字符串)。拿到 Key 后,通常的做法是把它放进环境变量(例如 \`OPENAI_API_KEY\`),而不是硬编码在代码里——这是为了避免密钥随代码一起被提交到 Git 仓库、造成泄露和被盗刷。

\`\`\`bash

# 在终端里设置环境变量(macOS / Linux)

export OPENAI_API_KEY="sk-你的密钥"

# 如果要长期生效,写进 shell 配置文件(zsh 用户)

echo 'export OPENAI_API_KEY="sk-你的密钥"' >> ~/.zshrc

source ~/.zshrc

# Windows PowerShell 里的写法

# setx OPENAI_API_KEY "sk-你的密钥"

\`\`\`

更规范的做法是在项目里用 \`.env\` 文件管理密钥,并把 \`.env\` 加入 \`.gitignore\`,配合 python-dotenv 库加载:

\`\`\`python

# 安装:pip install python-dotenv openai

from dotenv import load_dotenv

import os

load_dotenv() # 自动读取项目根目录下的 .env 文件

api_key = os.getenv("OPENAI_API_KEY")

if not api_key:

raise RuntimeError("没有找到 OPENAI_API_KEY,请检查 .env 文件")

\`\`\`

1.4 请求的基本结构:system / user / assistant 消息

调用对话类大模型 API 时,请求体的核心是一个消息列表(messages),每条消息有一个角色(role):

system:设定模型的身份、行为准则和回答风格,相当于"开场白设定",通常在对话开始时只出现一次。
user:用户实际发出的问题或指令。
assistant:模型此前的回复,用于在多轮对话中提供上下文。

模型会读取整个消息列表,理解"我是谁、用户问了什么、之前聊了什么",然后生成下一条 assistant 消息。关键要记住:模型本身是无状态的(stateless),它不会"记得"上一次你问了什么,所有的"记忆"都是靠你每次把完整的历史消息重新发过去实现的。

1.5 完整示例:命令行聊天机器人

下面是一个大约 30 行的最小可运行示例(以 \`openai\` 官方 SDK 风格为例,很多国内厂商的 SDK 接口设计与此高度相似,替换 base_url 和模型名即可复用):

\`\`\`python

from openai import OpenAI

client = OpenAI() # 会自动读取环境变量 OPENAI_API_KEY

# 维护一份对话历史,第一条是 system 消息

messages = [

{"role": "system", "content": "你是一个友好、简洁的中文助手。"}

]

print("命令行聊天机器人已启动,输入 'quit' 退出。")

while True:

user_input = input("你: ")

if user_input.strip().lower() == "quit":

break

messages.append({"role": "user", "content": user_input})

response = client.chat.completions.create(

model="gpt-4o-mini",

messages=messages,

temperature=0.7,

)

reply = response.choices[0].message.content

print(f"助手: {reply}")

# 把模型的回复也存入历史,保证下一轮对话有上下文

messages.append({"role": "assistant", "content": reply})

\`\`\`

这段代码做了四件事:构造请求(组装 messages)→ 发送请求(\`client.chat.completions.create\`)→ 解析响应(取出 \`choices[0].message.content\`)→ 维护上下文(把每一轮的用户输入和模型回复都追加进 messages 列表)。这就是所有基于 API 的对话应用的最小骨架,后续无论是加检索、加工具调用还是加记忆管理,都是在这个骨架上做扩展。

1.6 为什么产品级应用要用流式输出(Streaming)

上面的示例里,程序会等模型把整段回答全部生成完才一次性打印出来。如果回答比较长,用户可能要盯着空白屏幕等好几秒,体验很差。

流式输出(streaming)的做法是:模型每生成一小段文本(通常是一个 token 或几个字符),就立刻通过网络推送给客户端,客户端边收边显示,形成我们在 ChatGPT 等产品里看到的"逐字打字机"效果。

\`\`\`python

stream = client.chat.completions.create(

model="gpt-4o-mini",

messages=messages,

stream=True, # 开启流式输出

)

full_reply = ""

for chunk in stream:

delta = chunk.choices[0].delta.content

if delta:

print(delta, end="", flush=True)

full_reply += delta

print() # 收尾换行

\`\`\`

流式输出并没有让模型算得更快,总耗时基本不变,但它把"用户感知到的等待时间"从"生成完整答案的时间"降低到了"生成第一个 token 的时间"(这个指标常被称为 TTFT,Time To First Token)。对于问答、写作助手这类交互式产品,首字延迟对用户体验的影响远大于总生成时长,所以几乎所有严肃的 AI 产品都会选择流式接口,而不是等待完整响应再展示。

1.7 把多轮对话封装成一个可复用的类

在真实项目里,反复手写 messages 列表的追加逻辑既啰嗦又容易出错。更好的做法是把"维护历史 + 调用模型 + 控制上下文长度"封装成一个类,业务代码只管调用 \`chat()\` 方法:

\`\`\`python

from openai import OpenAI

class Conversation:

"""一个可复用的多轮对话封装,自带历史管理和长度截断。"""

def __init__(self, system_prompt: str, model: str = "gpt-4o-mini",

max_history: int = 20):

self.client = OpenAI()

self.model = model

self.max_history = max_history # 最多保留多少条历史消息(不含 system)

self.system_prompt = system_prompt

self.messages = [{"role": "system", "content": system_prompt}]

def _truncate(self):

# 超出上限时,保留 system 消息 + 最近的 max_history 条对话

if len(self.messages) - 1 > self.max_history:

head = self.messages[:1] # system 消息永远保留

tail = self.messages[-self.max_history:] # 最近的若干条

self.messages = head + tail

def chat(self, user_input: str, temperature: float = 0.7) -> str:

self.messages.append({"role": "user", "content": user_input})

self._truncate()

resp = self.client.chat.completions.create(

model=self.model,

messages=self.messages,

temperature=temperature,

)

reply = resp.choices[0].message.content

self.messages.append({"role": "assistant", "content": reply})

return reply

def reset(self):

"""清空对话,只保留最初的 system 设定。"""

self.messages = [{"role": "system", "content": self.system_prompt}]

# 使用起来就很干净

conv = Conversation("你是一个精通 Python 的编程助手。")

print(conv.chat("怎么反转一个列表?"))

print(conv.chat("那字典呢?")) # 模型能记住上文在聊反转

\`\`\`

这里最值得注意的是 \`_truncate\` 方法。大模型的上下文窗口(context window)是有限的,而且输入越长费用越高、延迟越大。当对话轮数很多时,必须有一个策略来裁剪历史——最简单的是"滑动窗口"(只保留最近 N 条),进阶做法是把更早的对话交给模型自己做摘要压缩后再保留,这就是所谓的"对话记忆管理"。

1.8 函数调用 / 工具调用(Function Calling)

概念上,函数调用(Function Calling,也叫 Tool Calling)指的是:你把一批"工具"(本质是你自己写的函数)的名字、用途和参数格式描述给模型,模型在需要时不会自己瞎编答案,而是返回一个结构化的"调用请求",告诉你"请帮我调用 get_weather 函数,参数是城市=北京"。你的代码执行真实函数、拿到结果,再把结果喂回给模型,模型据此生成最终自然语言回答。

这为什么重要?因为大模型本身不能上网、不能查数据库、不能执行代码,它只会"根据训练知识生成文本"。函数调用是把大模型从"只会聊天的语言模型"升级为"能真正做事的智能体(Agent)"的关键机制。

\`\`\`python

import json

from openai import OpenAI

client = OpenAI()

# 1. 定义你的真实业务函数

def get_weather(city: str) -> str:

# 真实项目里这里会去调气象 API,这里用假数据演示

fake_db = {"北京": "晴,25℃", "上海": "多云,28℃"}

return fake_db.get(city, "暂无该城市数据")

# 2. 用 JSON Schema 描述这个工具,让模型知道它的存在和用法

tools = [

{

"type": "function",

"function": {

"name": "get_weather",

"description": "查询指定城市的实时天气",

"parameters": {

"type": "object",

"properties": {

"city": {"type": "string", "description": "城市名,如 北京"}

},

"required": ["city"],

},

},

}

]

messages = [{"role": "user", "content": "北京今天天气怎么样?适合出门吗?"}]

# 3. 第一次调用:模型判断需要用工具,返回 tool_calls

resp = client.chat.completions.create(

model="gpt-4o-mini", messages=messages, tools=tools

)

msg = resp.choices[0].message

messages.append(msg)

# 4. 执行模型请求的工具调用

if msg.tool_calls:

for call in msg.tool_calls:

args = json.loads(call.function.arguments)

result = get_weather(**args) # 真正执行本地函数

messages.append({

"role": "tool",

"tool_call_id": call.id,

"content": result,

})

# 5. 把工具结果喂回去,模型据此生成最终答复

final = client.chat.completions.create(

model="gpt-4o-mini", messages=messages

)

print(final.choices[0].message.content)

\`\`\`

整个流程是一个"两次调用"的循环:第一次让模型决定用不用工具、用哪个、传什么参数;你执行工具;第二次把结果给模型让它组织语言。所有 Agent 框架(LangChain、LlamaIndex 等)的核心,本质上都是把这个循环自动化并支持多轮多工具调用。

1.9 让模型输出严格的 JSON(结构化输出)

很多时候我们不想要一段自然语言,而是想要能直接被程序解析的结构化数据,比如从一段简历里抽取"姓名、工作年限、技能列表"。如果只在提示词里说"请返回 JSON",模型有时会夹带解释文字(如"好的,这是您要的 JSON:...")导致解析失败。主流 API 提供了 JSON 模式来强制约束输出格式。

\`\`\`python

import json

from openai import OpenAI

client = OpenAI()

resume = "张三,Python 工程师,有 5 年后端经验,熟悉 Django、FastAPI 和 PostgreSQL。"

resp = client.chat.completions.create(

model="gpt-4o-mini",

# 强制模型只输出合法 JSON

response_format={"type": "json_object"},

messages=[

{

"role": "system",

"content": "你是信息抽取助手,只输出 JSON,字段为 name、years、skills(数组)。",

},

{"role": "user", "content": resume},

],

)

data = json.loads(resp.choices[0].message.content)

print(data["name"], data["years"], data["skills"])

# 输出可直接入库,无需再做字符串清洗

\`\`\`

注意两点:一是用 \`response_format\` 开启 JSON 模式的同时,system 提示里也要明确说明字段结构,二者配合才最稳;二是即便开了 JSON 模式,生产代码里仍要用 try/except 包住 \`json.loads\`,因为极端情况下(如被 token 上限截断)仍可能得到不完整的 JSON。

1.10 Token 计数与成本估算

Token 是大模型处理文本的基本单位,一个 token 大约相当于英文的 4 个字符或 0.75 个单词,中文则通常 1 个汉字约等于 1~2 个 token。API 按输入 token + 输出 token 的总量计费,所以在批量调用前先估算成本非常重要。可以用 \`tiktoken\` 库在本地精确计算 token 数,不必真的发请求。

\`\`\`python

# 安装:pip install tiktoken

import tiktoken

def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:

enc = tiktoken.encoding_for_model(model)

return len(enc.encode(text))

def estimate_cost(input_text: str, output_tokens: int,

price_in: float, price_out: float) -> float:

"""price_in / price_out 单位:美元 / 1K tokens"""

in_tokens = count_tokens(input_text)

cost = in_tokens / 1000 price_in + output_tokens / 1000 price_out

return cost

text = "请把下面这篇 2000 字的文章总结成三句话……"

# 假设某模型输入 0.15 美元/1K、输出 0.6 美元/1K,预计输出 100 token

cost = estimate_cost(text, output_tokens=100, price_in=0.15, price_out=0.6)

print(f"预计单次成本:约 ${cost:.4f} 美元")

print(f"跑 10 万次预计:约 ${cost * 100000:.2f} 美元")

\`\`\`

下面是一张不同定价档位下"每百万次调用"的粗略成本对比表,帮助你在选型时对费用量级有直观感受(价格随厂商政策变动,仅示意相对关系):

| 模型档位 | 输入价(美元/百万token) | 输出价(美元/百万token) | 单次约1K输入+200输出成本 | 百万次调用约需 |

|---------|------------------------|------------------------|------------------------|--------------|

| 小型/廉价模型 | 0.15 | 0.60 | 约 0.00027 美元 | 约 270 美元 |

| 中型主力模型 | 2.50 | 10.00 | 约 0.0045 美元 | 约 4500 美元 |

| 旗舰模型 | 15.00 | 60.00 | 约 0.027 美元 | 约 27000 美元 |

| 本地开源模型 | 仅电费/折旧 | 仅电费/折旧 | 近似 0 | 硬件一次性投入 |

结论很直观:高频、大规模、对效果要求不极致的任务(如批量分类、数据清洗)应优先选廉价小模型甚至本地模型;只有真正需要顶级推理能力的关键环节才用旗舰模型。很多成熟系统会做"模型路由",简单请求走小模型、复杂请求才升级到大模型,以此在效果和成本间取得平衡。

1.11 并发 / 异步批量调用

当你需要处理成千上万条数据(比如给一万条评论做情感分类)时,一条一条串行调用会慢到无法接受。每次网络请求的大部分时间都花在等待服务器响应上,这种 I/O 密集型任务非常适合用异步并发来提速。下面用 \`asyncio\` + 官方异步客户端,配合信号量限制并发数,避免瞬间打爆限流:

\`\`\`python

import asyncio

from openai import AsyncOpenAI

client = AsyncOpenAI()

# 信号量:最多同时 5 个请求在飞,防止触发限流

semaphore = asyncio.Semaphore(5)

async def classify(text: str) -> str:

async with semaphore:

resp = await client.chat.completions.create(

model="gpt-4o-mini",

messages=[

{"role": "system", "content": "判断情感,只回复:正面/负面/中性"},

{"role": "user", "content": text},

],

)

return resp.choices[0].message.content.strip()

async def batch_classify(texts: list[str]) -> list[str]:

tasks = [classify(t) for t in texts]

return await asyncio.gather(*tasks)

comments = ["这产品太好用了!", "客服态度很差", "还行吧一般般"]

results = asyncio.run(batch_classify(comments))

for c, r in zip(comments, results):

print(f"{r}\t{c}")

\`\`\`

相比串行,异步并发能把总耗时从"N 次请求相加"压缩到接近"最慢那批请求的时间"。实测处理一万条数据,串行可能要几十分钟,5~10 并发下往往几分钟就能跑完。核心技巧就是用信号量(Semaphore)把并发数控制在厂商限流阈值以内,否则会大量收到 429 限流错误反而更慢。

1.12 超时与限流的指数退避重试(完整实现)

真实网络请求会遇到超时、限流(rate limit)、服务临时不可用等情况。生产代码里不能"裸调用",至少要加上异常捕获,并对限流类错误做指数退避重试(等待时间逐次翻倍,再叠加一点随机抖动避免"惊群效应"),否则一次网络抖动就可能让整个服务连带崩溃。

\`\`\`python

import time

import random

from openai import OpenAI, RateLimitError, APITimeoutError, APIError

client = OpenAI(timeout=30.0) # 单次请求 30 秒超时

def call_with_retry(messages, model="gpt-4o-mini", max_retries=5):

for attempt in range(max_retries):

try:

return client.chat.completions.create(

model=model, messages=messages

)

except (RateLimitError, APITimeoutError) as e:

if attempt == max_retries - 1:

raise # 最后一次仍失败就抛出

# 指数退避 + 随机抖动:1s、2s、4s、8s... 各加 0~1s 抖动

wait = 2 ** attempt + random.uniform(0, 1)

print(f"第 {attempt + 1} 次失败({type(e).__name__}),{wait:.1f}s 后重试")

time.sleep(wait)

except APIError as e:

# 5xx 服务端错误也重试,4xx 参数错误直接抛(重试无意义)

if 500 <= getattr(e, "status_code", 0) < 600:

time.sleep(2 ** attempt)

continue

raise

raise RuntimeError("多次重试后仍然失败")

\`\`\`

区分错误类型是关键:限流(429)和服务端 5xx 错误重试有意义,而参数错误、认证失败这类 4xx 错误重试多少次都没用,应立即抛出。指数退避的意义在于:如果是因为你请求太快被限流,越等越久地重试才能让系统缓过气来,固定间隔的密集重试反而会让限流持续更久。

1.13 一行代码切换到国内厂商或本地模型

由于主流接口都兼容 OpenAI 的 messages 格式,切换服务商往往只需要改 \`base_url\` 和 \`api_key\` 两个参数,业务逻辑完全不用动。这也是为什么建议初学者就用 OpenAI 兼容风格写代码——它几乎是事实标准。

\`\`\`python

from openai import OpenAI

# 方案一:调用某个兼容 OpenAI 接口的国内厂商

client_cn = OpenAI(

api_key="你的国内厂商-key",

base_url="https://api.某厂商.com/v1",

)

# 方案二:调用本地 Ollama(下一部分会讲如何启动它)

client_local = OpenAI(

api_key="ollama", # 本地不校验,随便填

base_url="http://localhost:11434/v1",

)

# 业务代码完全一致,只是换了 client 和模型名

resp = client_local.chat.completions.create(

model="llama3:8b",

messages=[{"role": "user", "content": "你好"}],

)

print(resp.choices[0].message.content)

\`\`\`

有了这个能力,你可以在开发调试阶段用便宜的小模型或本地模型,上线时无缝切到旗舰模型;也可以做"降级容错":主用的云端 API 挂了就自动切到备用厂商或本地模型,保证服务不中断。

1.14 两个必须理解的核心参数

temperature(温度):控制输出的"随机程度"。数值越接近 0,模型越倾向于每次都给出最"稳妥"、最确定的答案,适合需要稳定、可复现结果的场景(如代码生成、结构化数据抽取、分类任务);数值越高(比如 1 以上),输出越发散、越有创意,适合头脑风暴、创意写作等场景。没有一个"万能值",需要根据任务性质调整。
max_tokens(最大输出长度):限制模型单次回复最多生成多少 token。设太小会导致回答被硬生生截断,设太大则可能浪费费用。要根据任务预期输出长度合理设置,并在代码里检查返回的 \`finish_reason\`,如果是 \`length\` 说明被截断了,需要续写或调大上限。

1.15 真实案例:批量给客服工单打标签

某电商团队每天收到上万条客服工单,需要自动归类为"退款、物流、质量、咨询、投诉"五类,再分配给不同小组。原来靠人工标注每天要两个专职人员,现在用小模型 + 结构化输出 + 异步批量,一台笔记本几分钟就能跑完当天的量,准确率约 92%,人工只需抽检和处理疑难件。

\`\`\`python

import asyncio

import json

from openai import AsyncOpenAI

client = AsyncOpenAI()

sem = asyncio.Semaphore(8)

LABELS = ["退款", "物流", "质量", "咨询", "投诉"]

async def tag_ticket(text: str) -> dict:

async with sem:

resp = await client.chat.completions.create(

model="gpt-4o-mini",

temperature=0, # 分类任务要稳定,温度设 0

response_format={"type": "json_object"},

messages=[

{"role": "system",

"content": f"给客服工单分类,类别只能是{LABELS}之一。"

"输出 JSON:label、confidence(0-1)。"},

{"role": "user", "content": text},

],

)

return json.loads(resp.choices[0].message.content)

async def main(tickets: list[str]):

results = await asyncio.gather(*[tag_ticket(t) for t in tickets])

# 置信度低的挑出来给人工复核

for t, r in zip(tickets, results):

flag = " ← 需人工复核" if r["confidence"] < 0.7 else ""

print(f"[{r['label']}] {t[:20]}...{flag}")

asyncio.run(main(["我要退货,衣服有破洞", "快递三天了还没到", "怎么开发票?"]))

\`\`\`

这个案例把前面几个知识点串起来了:小模型控成本、temperature=0 保稳定、JSON 模式保证可解析、置信度字段实现"人机协同"(低置信度转人工)、异步并发保证吞吐。这几乎是所有"用大模型做批量数据处理"任务的通用范式。

1.16 API 调用常见坑

| 坑 | 现象 | 解决办法 |

|----|------|---------|

| 忘记维护历史 | 模型"失忆",多轮对话失去上下文 | 每次把完整 messages 发过去 |

| 上下文越堆越长 | 费用飙升、延迟变大甚至超窗报错 | 滑动窗口或摘要压缩历史 |

| 裸调用无重试 | 一次网络抖动导致整个任务失败 | 指数退避重试 + 超时设置 |

| 并发无限制 | 大量 429 限流错误 | 用信号量控制并发数 |

| JSON 解析崩溃 | 模型夹带解释文字或被截断 | 开 JSON 模式 + try/except |

| 密钥硬编码进代码 | 泄露被盗刷 | 用环境变量 / .env 文件 |

1.17 API 调用最佳实践小结

先用便宜小模型跑通流程再考虑升级;分类抽取类任务温度设 0、开 JSON 模式;所有生产调用都要有超时、重试和并发控制;上线前用 tiktoken 估算成本并设预算告警;用 OpenAI 兼容风格写代码以便随时切换服务商。

🖥️ 第二部分:本地部署开源模型

2.1 概念:什么是本地部署

本地部署指的是把开源模型的权重文件下载到自己的机器(个人电脑、公司服务器或私有云)上,用本地的 CPU/GPU 运行推理,全程不依赖任何外部网络服务。你既是"用户"也是"运维",掌握从模型选择、量化、推理引擎到服务暴露的全链路。

2.2 为什么要在本地跑开源模型

调用云端 API 简单快捷,但并非所有场景都适用:

隐私与合规:处理内部文档、客户数据或敏感信息时,把数据发到第三方服务器可能违反公司政策或行业合规要求,本地部署可以让数据完全不出内网。
离线可用:没有网络或网络不稳定的环境(内网、边缘设备、飞机上做演示)依然可以正常使用。
成本可控:按 token 计费在大批量调用时费用会迅速累积,本地跑开源模型的边际成本只有电费和硬件折旧,适合高频、大规模的调用场景。
可定制:本地模型可以自由微调、修改推理参数,甚至魔改模型结构,不受厂商 API 接口能力的限制。

代价也很明显:需要一定的硬件资源(尤其是显存),推理速度和效果通常不如最顶尖的闭源大模型,还需要自己维护部署环境。

2.3 Ollama:本地跑模型最简单的方式

Ollama 是目前最流行的本地大模型运行工具之一,它把"下载模型权重、配置推理引擎、启动服务"这一整套繁琐流程封装成了几条简单命令,底层其实是对 llama.cpp 的封装。

\`\`\`bash

# 安装完成后,拉取一个开源模型(以 Llama 3 的 8B 版本为例)

ollama pull llama3:8b

# 直接在命令行里和模型对话

ollama run llama3:8b

# 查看本地已经下载了哪些模型

ollama list

# Ollama 默认会在本地启动一个 HTTP 服务,可以像调用云端 API 一样调用它

curl http://localhost:11434/api/generate -d '{

"model": "llama3:8b",

"prompt": "用一句话介绍量子计算",

"stream": false

}'

\`\`\`

注意最后一条命令:Ollama 暴露的本地接口和云端 API 的调用方式非常相似,这意味着前面写的"30 行聊天机器人"代码,稍微改一下 base_url 和模型名,就可以无缝切换到调用本地模型,几乎不需要重写业务逻辑。

2.4 用 Modelfile 自定义你自己的模型

Ollama 允许通过一个叫 Modelfile 的配置文件(语法类似 Dockerfile)来定制模型:设定固定的 system 提示词、调整默认参数、甚至基于已有模型"烘焙"出一个带特定人设的新模型。这在需要把某个角色或规则固化下来时非常实用。

\`\`\`bash

# 新建一个名为 Modelfile 的文件,内容如下:

# ---------------------------------------------

# FROM llama3:8b

#

# # 固定的系统人设

# SYSTEM 你是一个只用简体中文、语气简洁专业的法律咨询助手,不确定时明确说明。

#

# # 默认推理参数

# PARAMETER temperature 0.3

# PARAMETER num_ctx 4096

# ---------------------------------------------

# 基于 Modelfile 创建自定义模型

ollama create legal-assistant -f ./Modelfile

# 之后就能像普通模型一样使用它

ollama run legal-assistant "合同违约金上限有规定吗?"

\`\`\`

这样创建出来的 \`legal-assistant\` 每次启动就自带那套 system 人设和参数,团队成员共享同一个 Modelfile 就能保证行为一致,非常适合把"精心调好的提示词 + 参数组合"固化成可分发的资产。

2.5 通过 OpenAI 兼容接口调用 Ollama

Ollama 除了自己的原生接口,还提供了一套完全兼容 OpenAI 的接口(在 \`/v1\` 路径下),这意味着任何用 OpenAI SDK 写的应用都能零改动地指向本地 Ollama:

\`\`\`python

from openai import OpenAI

client = OpenAI(

base_url="http://localhost:11434/v1",

api_key="ollama", # 本地不校验

)

# 流式调用本地模型,体验和调用云端完全一致

stream = client.chat.completions.create(

model="llama3:8b",

messages=[{"role": "user", "content": "写一首关于秋天的五言绝句"}],

stream=True,

)

for chunk in stream:

delta = chunk.choices[0].delta.content

if delta:

print(delta, end="", flush=True)

print()

\`\`\`

有了这层兼容,你在第一部分学到的所有技巧——多轮对话封装、函数调用、结构化输出、异步并发——大部分都能直接搬到本地模型上(函数调用需模型本身支持)。开发用本地免费调试、生产切云端旗舰,或反过来出于隐私把生产也放本地,都只是改一个 base_url。

2.6 llama.cpp 与 GGUF 格式简介

Ollama 底层依赖的 llama.cpp 是一个用 C/C++ 写的高效推理引擎,最大特点是能在纯 CPU 上跑大模型,也支持 GPU 加速,对硬件要求极低,是"让大模型跑在普通设备上"这件事的基石。它使用一种叫 GGUF 的模型文件格式,把模型权重和元数据打包在一个文件里,并内置了各种量化版本。

\`\`\`bash

# 克隆并编译 llama.cpp(macOS 会自动用 Metal 做 GPU 加速)

git clone https://github.com/ggerganov/llama.cpp

cd llama.cpp

make

# 从 Hugging Face 下载一个 GGUF 量化模型文件(体积通常几个 GB)

# 然后直接推理

./llama-cli -m ./models/llama3-8b-q4_k_m.gguf -p "介绍一下 GGUF 格式" -n 128

# 或者启动一个兼容 OpenAI 接口的本地服务

./llama-server -m ./models/llama3-8b-q4_k_m.gguf --port 8080

\`\`\`

如果 Ollama 已经能满足你,通常不需要直接用 llama.cpp;但当你想用某个 Ollama 仓库里没有的模型、或者想更精细地控制编译选项和量化时,直接从 Hugging Face 下 GGUF 文件配合 llama.cpp 就是必备技能。

2.7 vLLM:高吞吐生产级部署简介

Ollama 和 llama.cpp 面向的是"个人/单机/低并发"场景。当你要为一个真实产品提供服务、需要同时应对大量并发请求时,vLLM 是更专业的选择。它的核心技术叫 PagedAttention,能极大提升显存利用率和吞吐量,是当前开源界高性能推理部署的主流方案,通常跑在带独立 GPU 的服务器上。

\`\`\`bash

# 安装(需要 NVIDIA GPU 和 CUDA 环境)

pip install vllm

# 一条命令启动一个兼容 OpenAI 接口的高吞吐服务

python -m vllm.entrypoints.openai.api_server \

--model meta-llama/Meta-Llama-3-8B-Instruct \

--dtype auto \

--max-model-len 8192 \

--gpu-memory-utilization 0.9

# 服务起来后,同样用 OpenAI SDK 调用(端口默认 8000)

\`\`\`

选型直觉可以这样记:个人玩票/隐私自用/离线 demo 用 Ollama;想极致压榨单机硬件、跑非常规模型用 llama.cpp;要给多用户提供稳定高并发服务、追求吞吐和延迟就上 vLLM(或 TGI、SGLang 等同类方案)。

2.8 模型量化:为什么 7B 模型也能在笔记本上跑

一个原始的、未经压缩的大模型,参数通常以 16 位浮点数(FP16)甚至 32 位浮点数(FP32)存储。以一个 70 亿参数(7B)的模型为例,FP16 精度下仅存储权重就需要 \`7,000,000,000 × 2 字节 ≈ 14GB\` 显存,这已经超过大多数消费级显卡的容量,更不用说 13B、70B 这些更大的模型了。

量化(Quantization) 的核心思路是:把权重从 16 位、32 位浮点数压缩到更低的位宽(比如 8 位整数甚至 4 位整数)来存储和计算,从而大幅降低显存占用和计算开销。

原理:把权重的取值范围映射到更少的离散数值上(比如从连续的浮点数范围映射到 -8 到 7 共 16 个整数),推理时再按比例(scale)还原回近似的浮点数参与计算。现代量化方法(如 K-quants)还会对不同重要程度的权重用不同精度,重要的层保留更高精度,以在同样体积下尽量保住效果。
收益:4-bit 量化相比 FP16 能把显存占用降低到约四分之一,这意味着原本需要 14GB 显存的 7B 模型,量化后可能只需要 4~5GB 左右,普通笔记本电脑的核显或入门级独立显卡就能跑起来。
代价:位宽越低,权重的表示精度越粗糙,模型的输出质量可能出现一定程度下降——常见现象是逻辑推理能力减弱、长文本一致性变差。但实践中 4-bit 到 8-bit 量化对大多数日常任务(聊天、摘要、简单问答)的效果影响并不明显,这也是为什么它成为本地部署的主流选择,是显存/成本与效果之间的一个务实折中。

2.9 主流量化格式对比

在 Ollama 或 Hugging Face 上下载模型时,你会经常看到 \`llama3:8b-instruct-q4_k_m\`、\`GPTQ\`、\`AWQ\` 这些命名。它们分属不同的量化路线,适用场景各有侧重:

| 量化格式 | 典型位宽 | 载体/生态 | 特点 | 适用场景 |

|---------|---------|----------|------|---------|

| Q4_K_M | 4-bit(混合) | GGUF / llama.cpp / Ollama | 体积小、质量损失可控,最流行的默认选择 | CPU/消费级 GPU 本地部署首选 |

| Q5_K_M | 5-bit(混合) | GGUF / llama.cpp / Ollama | 比 Q4 质量更好,体积略大 | 显存稍宽裕、更看重质量时 |

| Q8_0 | 8-bit | GGUF / llama.cpp / Ollama | 几乎无损,体积约 FP16 一半 | 追求接近原版效果 |

| GPTQ | 4-bit | GPU 专用(transformers/vLLM) | GPU 推理快,需校准数据 | NVIDIA GPU 服务器部署 |

| AWQ | 4-bit | GPU 专用(vLLM 等) | 保护重要权重,质量常优于 GPTQ | GPU 高吞吐生产部署 |

简单记忆:本地 CPU 或消费级显卡、用 Ollama/llama.cpp,就选 GGUF 里的 Q4_K_M(默认)或 Q5_K_M(要质量);有独立 NVIDIA GPU 要上 vLLM 做生产服务,就选 GPTQ 或 AWQ 这类 GPU 原生量化格式。

2.10 显存估算:不同参数量到底需要多大显卡

选本地模型前最该算的一笔账就是显存够不够。一个粗略但实用的估算公式是:\`显存需求 ≈ 参数量(B) × 每参数字节数 × 1.2\`,其中 FP16 每参数 2 字节、8-bit 约 1 字节、4-bit 约 0.5 字节,乘以 1.2 是为推理时的激活值、KV 缓存留出余量(上下文越长,KV 缓存占用越大,这个系数还要往上调)。

\`\`\`python

def estimate_vram(params_billion: float, bits: int, overhead: float = 1.2) -> float:

"""粗略估算模型权重推理所需显存(GB)。"""

bytes_per_param = bits / 8

weight_gb = params_billion * bytes_per_param

return round(weight_gb * overhead, 1)

for name, p in [("7B", 7), ("13B", 13), ("70B", 70)]:

fp16 = estimate_vram(p, 16)

q8 = estimate_vram(p, 8)

q4 = estimate_vram(p, 4)

print(f"{name}: FP16≈{fp16}GB Q8≈{q8}GB Q4≈{q4}GB")

# 输出:

# 7B: FP16≈16.8GB Q8≈8.4GB Q4≈4.2GB

# 13B: FP16≈31.2GB Q8≈15.6GB Q4≈7.8GB

# 70B: FP16≈168.0GB Q8≈84.0GB Q4≈42.0GB

\`\`\`

把这个估算整理成表,对照常见显卡就能快速判断"我这张卡能跑多大的模型":

| 模型 | FP16 显存 | Q8 显存 | Q4 显存 | Q4 下可用的典型硬件 |

|------|----------|--------|--------|-------------------|

| 7B | 约 17GB | 约 8GB | 约 4~5GB | 8GB 显存的消费级显卡 / Apple 芯片 |

| 13B | 约 31GB | 约 16GB | 约 8GB | 12GB 显存显卡 |

| 34B | 约 82GB | 约 41GB | 约 20GB | 24GB 显存显卡(如高端消费卡) |

| 70B | 约 168GB | 约 84GB | 约 42GB | 双卡 24GB 或单张专业卡 48GB |

2.11 GPU 选型对比

如果要认真投入本地部署,硬件选择直接决定你能跑多大的模型、跑多快。下面按常见定位做个粗略对比(具体型号会随时间更新,重点看"显存容量"和"是否统一内存"这两个维度):

| 硬件类型 | 显存/内存 | 优势 | 局限 | 适合谁 |

|---------|----------|------|------|-------|

| 入门消费级独显 | 8~12GB | 便宜、CUDA 生态完善 | 只能跑到 7B~13B 量化版 | 个人学习、轻量应用 |

| 高端消费级独显 | 16~24GB | 能跑 34B 量化、性价比高 | 单卡跑不动 70B 原版 | 认真的个人开发者、小团队 |

| Apple 芯片(统一内存) | 16~128GB | 统一内存可当显存用、能耗低、静音 | 生态不如 CUDA、部分库支持有限 | Mac 用户本地开发、大内存跑大模型 |

| 专业级/数据中心 GPU | 40~80GB+ | 显存大、可多卡互联、跑 70B+ | 昂贵、功耗高 | 企业生产、微调训练 |

一个务实的建议:如果你只是学习和搭个人应用,一台内存较大的 Apple 芯片笔记本或一张 16GB 的消费级显卡已经足够玩转 7B~13B 的量化模型;只有当你要为团队/产品提供服务或做模型微调时,才需要考虑专业级硬件或直接租用云 GPU。

2.12 真实案例:律所内网合同审查助手

某律师事务所出于保密要求,绝对不允许把客户合同上传到任何外部 API。他们的方案是:在一台配 24GB 显存显卡的内网服务器上,用 Ollama 跑一个 34B 参数的 Q4_K_M 量化中文模型,配合 Modelfile 固化"法律助手"人设,再套一层 RAG(下一部分讲)接入内部判例库。全程数据不出内网,满足合规;34B 量化后显存占用约 20GB 正好塞得下;日常合同条款审查、风险点提示的准确率满足初审需求,律师只需复核。这个案例完美展示了本地部署的核心价值——用可接受的效果折损,换取数据主权和零边际成本。

2.13 本地部署常见坑

| 坑 | 现象 | 解决办法 |

|----|------|---------|

| 显存不够硬上 | 加载报 OOM 或退化到极慢的 CPU 推理 | 先用公式估算,选更小参数量或更低位宽 |

| 忽视上下文的显存开销 | 短对话正常,长文档一喂就爆显存 | KV 缓存随上下文线性增长,留足余量 |

| 量化过度 | 用 2-bit/3-bit 后模型明显变笨 | 日常优先 Q4_K_M 起步,别盲目追极限压缩 |

| 拿本地小模型对标旗舰 | 觉得"开源模型不行" | 明确本地是隐私/成本的折中,非效果对标 |

| 用错推理引擎 | 高并发场景用 Ollama 吞吐上不去 | 生产高并发换 vLLM 等专业引擎 |

2.14 本地部署最佳实践小结

先用估算公式确认硬件能否跑起目标模型;个人本地首选 Ollama + GGUF 的 Q4_K_M;把调好的人设和参数用 Modelfile 固化下来复用;用 OpenAI 兼容接口对接,方便本地/云端随时切换;生产高并发再上 vLLM;始终记住本地部署换来的是数据主权和边际零成本,而非效果对标闭源旗舰。

📚 第三部分:实战 RAG,搭建你的私人知识库

3.1 概念:什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成)的原理并不复杂:让大模型在回答问题之前,先从你自己的文档库里检索出相关内容,再把检索结果和问题一起交给模型生成答案,这样模型就能回答它训练数据里没有、但你私有文档里有的问题。

3.2 为什么需要 RAG

大模型有三个天生的局限,RAG 恰好都能缓解:

知识有截止日期:模型只知道训练时见过的东西,无法回答训练之后发生的新事、更无法知道你公司内部的私有信息。RAG 让它"现查现答"。
会一本正经地胡说(幻觉):模型不知道答案时倾向于编造。RAG 提供确凿资料并要求"只根据资料回答",能大幅压制幻觉。
可溯源:因为答案是基于检索到的具体文档片段生成的,可以把来源一并返回,用户能核实,这在企业场景里至关重要。

相比"微调模型来注入知识",RAG 的优势是知识更新只需更新文档库(增删改文件即可),无需重新训练,成本和时效都好得多。

3.3 RAG 的最小骨架(伪代码理解流程)

原理讲起来容易,但很多人止步于"知道原理",从未真正跑通一个 RAG 应用。先用一段伪代码把完整流程刻在脑子里:

\`\`\`python

# 伪代码:最小可用 RAG 骨架,展示完整流程而非工业级实现

from embedding_model import embed_text # 任意 embedding 模型的封装

from vector_store import LocalVectorStore # 如 FAISS / Chroma 的简单封装

from llm_client import chat # 前面写好的 API 调用函数

# ---------- 阶段一:构建知识库(离线,只需做一次)----------

def build_knowledge_base(doc_paths, store: LocalVectorStore):

for path in doc_paths:

text = read_document(path) # 1. 读取文档(PDF/Markdown/txt)

chunks = split_into_chunks(text, chunk_size=500, overlap=50)

# 2. 切块:把长文档切成一个个几百字的小段,overlap 避免语义在切口处断裂

for chunk in chunks:

vector = embed_text(chunk) # 3. embedding:把文本转成向量

store.add(vector=vector, text=chunk, source=path)

# 4. 存入本地向量库,同时保留原文和来源,方便回答时溯源

# ---------- 阶段二:回答用户问题(在线,每次提问都会执行)----------

def answer_question(question, store: LocalVectorStore):

query_vector = embed_text(question) # 5. 把用户问题也转成向量

top_chunks = store.search(query_vector, top_k=3)

# 6. 检索:在向量库里找出与问题最相似的几个文本片段

context = "\n\n".join(chunk.text for chunk in top_chunks)

prompt = f"""请仅根据下面提供的资料回答问题,如果资料中没有相关信息,请直接说不知道。

资料:

{context}

问题:{question}

回答:"""

# 7. 拼接 prompt:把检索到的资料和问题组装成一个完整提示

return chat(prompt) # 8. 调用 LLM 生成最终答案

# ---------- 使用示例 ----------

store = LocalVectorStore()

build_knowledge_base(["产品手册.pdf", "常见问题.md"], store)

print(answer_question("退货政策是怎样的?", store))

\`\`\`

它完整覆盖了 RAG 的每一个必经步骤:读取文档 → 切块 → embedding → 存入向量库 → 检索 → 拼接 prompt → 调用大模型生成回答。下面我们把伪代码换成真正能跑的实现。

3.4 用 ChromaDB 实现一个真能跑的 RAG

ChromaDB 是一个自带持久化和元数据过滤、上手极快的向量数据库,非常适合做原型。下面这段代码你装好依赖后可以直接运行:

\`\`\`python

# 安装:pip install chromadb openai

import chromadb

from chromadb.utils import embedding_functions

from openai import OpenAI

llm = OpenAI()

# 1. 用 OpenAI 的 embedding 接口作为向量化函数

ef = embedding_functions.OpenAIEmbeddingFunction(

model_name="text-embedding-3-small"

)

# 2. 创建一个持久化的向量库(数据存到本地磁盘 ./chroma_db)

client = chromadb.PersistentClient(path="./chroma_db")

collection = client.get_or_create_collection(

name="my_kb", embedding_function=ef

)

def simple_chunk(text: str, size: int = 500, overlap: int = 50):

chunks, start = [], 0

while start < len(text):

chunks.append(text[start:start + size])

start += size - overlap

return chunks

# 3. 构建知识库:切块后写入(Chroma 自动帮你做 embedding)

def add_document(doc_id: str, text: str):

chunks = simple_chunk(text)

collection.add(

ids=[f"{doc_id}-{i}" for i in range(len(chunks))],

documents=chunks,

metadatas=[{"source": doc_id} for _ in chunks],

)

# 4. 检索 + 生成

def ask(question: str, top_k: int = 3) -> str:

hits = collection.query(query_texts=[question], n_results=top_k)

context = "\n\n".join(hits["documents"][0])

prompt = (f"仅根据以下资料回答,没有相关信息就说不知道。\n\n"

f"资料:\n{context}\n\n问题:{question}")

resp = llm.chat.completions.create(

model="gpt-4o-mini",

messages=[{"role": "user", "content": prompt}],

)

return resp.choices[0].message.content

add_document("退货政策", "本店支持 7 天无理由退货,商品需保持完好……")

print(ask("多少天内可以退货?"))

\`\`\`

对比伪代码,这里的关键升级是:向量化和存储由 Chroma 托管、数据持久化到磁盘(重启不丢)、支持按元数据过滤(比如只在某个来源里检索)。把 embedding 函数换成本地模型(如通过 Ollama 的 embedding 接口),整套系统就能完全离线运行。

3.5 用 FAISS 实现(更轻量、适合单机大批量)

FAISS 是 Facebook 开源的向量检索库,极其轻量高效,适合本地单机、数据量较大且不需要数据库那套元数据管理的场景。它不自带 embedding,需要你自己把文本转成向量:

\`\`\`python

# 安装:pip install faiss-cpu numpy openai

import faiss

import numpy as np

from openai import OpenAI

client = OpenAI()

DIM = 1536 # text-embedding-3-small 的向量维度

index = faiss.IndexFlatL2(DIM) # 用 L2 距离做暴力精确检索

texts = [] # 与 index 中向量一一对应的原文

def embed(text: str) -> np.ndarray:

v = client.embeddings.create(

model="text-embedding-3-small", input=text

).data[0].embedding

return np.array(v, dtype="float32")

def add(text: str):

index.add(embed(text).reshape(1, -1))

texts.append(text)

def search(query: str, top_k: int = 3):

_, idx = index.search(embed(query).reshape(1, -1), top_k)

return [texts[i] for i in idx[0] if i != -1]

for t in ["支持 7 天无理由退货", "满 99 元包邮", "客服工作时间 9-18 点"]:

add(t)

print(search("怎么退货")) # 会返回最相关的退货那条

\`\`\`

FAISS 的 \`IndexFlatL2\` 是暴力精确检索,数据量特别大(百万级以上)时可以换成 \`IndexIVFFlat\` 或 \`IndexHNSWFlat\` 这类近似检索索引,用一点点召回精度换取数量级的速度提升。ChromaDB 和 FAISS 的选择:要开箱即用、要元数据过滤、要持久化选 Chroma;要极致轻量、自己完全掌控、超大数据量选 FAISS。

3.6 chunk 切分策略对比

切块(chunking)看似不起眼,却是最影响 RAG 效果的环节之一。切得太大,检索到的片段夹带大量无关内容,稀释了信息、还浪费 token;切得太小,又可能把一个完整语义切碎,检索到的片段缺乏上下文。常见策略对比:

| 策略 | 做法 | 优点 | 缺点 | 适用 |

|------|------|------|------|------|

| 固定长度 | 每 N 字符切一刀 | 实现最简单 | 可能从句子中间切断 | 快速原型 |

| 固定长度+重叠 | 相邻块留 overlap | 缓解切口语义断裂 | 有冗余 | 通用默认选择 |

| 按句子/段落 | 按标点或空行切 | 保持语义完整 | 长度不均 | 结构清晰的文档 |

| 递归切分 | 按标题>段落>句子逐级切 | 兼顾结构与长度 | 实现较复杂 | 长文档、技术手册 |

| 按语义 | 用 embedding 找语义边界 | 切得最"聪明" | 计算开销大 | 对质量要求高的场景 |

下面是一个比固定长度更好用的"带重叠 + 优先在句号处断开"的切分实现:

\`\`\`python

import re

def smart_chunk(text: str, size: int = 500, overlap: int = 80):

# 先按中文句号/换行粗分成句子

sentences = re.split(r"(?<=[。!?\n])", text)

chunks, cur = [], ""

for s in sentences:

if len(cur) + len(s) <= size:

cur += s

else:

if cur:

chunks.append(cur)

# 新块从上一块结尾 overlap 个字符续起,保留上下文

cur = cur[-overlap:] + s if cur else s

if cur:

chunks.append(cur)

return chunks

doc = "第一段内容。第二段内容。第三段稍微长一点的内容……"

for i, c in enumerate(smart_chunk(doc, size=20, overlap=5)):

print(i, repr(c))

\`\`\`

一个经验值:通用文档 chunk_size 取 300~800 字、overlap 取 chunk_size 的 10%~20% 是不错的起点,再根据实际检索效果调整。

3.7 embedding 模型的选择

embedding 模型负责把文本转成向量,它的质量直接决定"语义相近的内容能不能被检索到"。选择时主要权衡效果、维度(影响存储和检索速度)、是否支持中文、以及走云端还是本地:

| embedding 方案 | 维度 | 特点 | 适用 |

|---------------|------|------|------|

| OpenAI text-embedding-3-small | 1536 | 效果好、便宜、支持多语言 | 云端通用首选 |

| OpenAI text-embedding-3-large | 3072 | 效果更强、维度更高 | 对检索质量要求高 |

| BGE / M3E(开源中文) | 512~1024 | 中文效果好、可本地跑 | 隐私敏感、中文为主 |

| 多语言开源模型 | 384~768 | 轻量、可离线 | 边缘设备、离线场景 |

关键提醒:构建知识库时用的 embedding 模型,和检索提问时用的必须是同一个,否则两边的向量不在同一个"语义空间"里,检索会完全失效。换 embedding 模型意味着整个向量库要重新构建。

3.8 检索 top_k 的调优

top_k 指每次检索返回多少个最相关的片段。它是 RAG 里一个需要权衡的关键旋钮:top_k 太小可能漏掉关键信息导致答不全;太大则把无关内容也塞进 prompt,既稀释信息、增加幻觉风险,又抬高 token 成本和延迟。

\`\`\`python

def ask_with_k(question: str, collection, llm, top_k: int):

hits = collection.query(query_texts=[question], n_results=top_k)

docs = hits["documents"][0]

context = "\n\n".join(f"[{i+1}] {d}" for i, d in enumerate(docs))

prompt = (f"仅根据资料回答,并在末尾标注引用了哪几条[编号]。\n\n"

f"资料:\n{context}\n\n问题:{question}")

resp = llm.chat.completions.create(

model="gpt-4o-mini", temperature=0,

messages=[{"role": "user", "content": prompt}],

)

return resp.choices[0].message.content

# 用不同 top_k 跑同一个问题,人工对比答案质量和引用命中情况

for k in (2, 4, 8):

print(f"===== top_k={k} =====")

print(ask_with_k("退货需要满足什么条件?", collection, llm, k))

\`\`\`

实践中 top_k 常取 3~5 作为起点。一个更稳的做法是让模型在答案里标注它引用了哪几条资料(如上面代码),这样你既能评估检索质量,又给了用户溯源能力。

3.9 加入重排序(Rerank)提升精度

向量检索(也叫召回)追求的是"快",它用一次向量相似度计算从海量片段里粗选出一批候选,但相似度高不代表真的最相关。重排序(rerank)的思路是:先用向量检索多召回一些候选(比如 top 20),再用一个更精细、专门判断"query 和文档相关性"的重排序模型对这批候选精算打分,取分数最高的几个真正喂给大模型。这是"粗召回 + 精排序"两段式检索,能显著提升最终喂给模型的资料质量。

\`\`\`python

# 概念示例:召回 20 条,rerank 后取最相关的 3 条

def retrieve_and_rerank(question: str, collection, reranker, final_k: int = 3):

# 1. 粗召回:向量检索多拿一些候选

hits = collection.query(query_texts=[question], n_results=20)

candidates = hits["documents"][0]

# 2. 精排序:reranker 对 (问题, 每个候选) 打相关性分

# reranker 可以是本地的 cross-encoder 模型,也可以是云端 rerank API

pairs = [(question, doc) for doc in candidates]

scores = reranker.compute_scores(pairs) # 返回每条候选的相关性分数

# 3. 按分数降序,取前 final_k 条

ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)

return [doc for doc, _ in ranked[:final_k]]

\`\`\`

向量检索用的是"双塔"结构(问题和文档各自独立编码再算距离),速度快但精度有限;rerank 用的是"交叉编码"(把问题和文档拼在一起送进模型判断),精度高但慢,所以只对少量候选做。二者配合是当前 RAG 提升效果最立竿见影的手段之一。

3.10 混合检索简介

纯向量检索擅长理解语义("退货"能匹配到"退款""退换"),但对精确的关键词、专有名词、编号(如产品型号 ABC-123、法条编号)反而不如传统的关键词检索(如 BM25)敏感。混合检索(Hybrid Search)就是把两者结合:同时跑向量检索和关键词检索,再把两路结果融合排序。

\`\`\`python

# 概念示例:融合向量检索和关键词检索的结果(RRF 倒数排名融合)

def hybrid_search(question, vector_hits, keyword_hits, k: int = 60):

# vector_hits / keyword_hits 都是按相关性排好序的文档 id 列表

scores = {}

for rank, doc_id in enumerate(vector_hits):

scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

for rank, doc_id in enumerate(keyword_hits):

scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

# 两路都靠前的文档综合得分最高

return sorted(scores, key=scores.get, reverse=True)

\`\`\`

这里用的 RRF(Reciprocal Rank Fusion,倒数排名融合)是一种简单有效的结果融合方法:一个文档在两路检索里排名都靠前,综合分就高。混合检索在企业知识库(既有自然语言问题、又有大量专有名词和编号)场景里几乎是标配。

3.11 怎么评估 RAG 效果

"感觉答得不错"不是工程,可量化的评估才是。RAG 的评估要分开看两个环节——检索质量和生成质量,因为答错可能是没检索到(检索问题),也可能是检索到了但模型没用好(生成问题)。

| 维度 | 指标 | 含义 |

|------|------|------|

| 检索 | 命中率 / 召回率 | 相关片段是否被检索到了 |

| 检索 | 命中排名(MRR) | 相关片段排得够不够靠前 |

| 生成 | 忠实度(Faithfulness) | 答案是否严格基于检索资料,没有编造 |

| 生成 | 答案相关性 | 答案是否切题、回答了问题 |

| 生成 | 上下文利用率 | 检索到的资料被用上了多少 |

一个务实的起步做法是自建一个小评测集(几十条"问题→标准答案→应命中的文档"),跑一遍算出上面的指标,之后每次调参(改 chunk 大小、换 embedding、加 rerank)都跑一遍对比,用数据说话:

\`\`\`python

def evaluate_retrieval(testset, retrieve_fn, top_k: int = 5):

"""testset: [{'question':..., 'gold_doc_id':...}, ...]"""

hit, mrr = 0, 0.0

for case in testset:

retrieved_ids = retrieve_fn(case["question"], top_k)

if case["gold_doc_id"] in retrieved_ids:

hit += 1

rank = retrieved_ids.index(case["gold_doc_id"]) + 1

mrr += 1 / rank # 命中排名越靠前,贡献越大

n = len(testset)

print(f"命中率 Hit@{top_k}: {hit / n:.2%}")

print(f"平均倒数排名 MRR: {mrr / n:.3f}")

\`\`\`

有了这套评估,"加了 rerank 到底有没有用""chunk 从 500 改到 300 是好是坏"这类问题就能用数字回答,而不是靠感觉。RAGAS 等专门的开源库还能借助大模型自动评估忠实度、相关性等更主观的生成质量指标。

3.12 真实案例三则

案例一:企业内网知识库。 一家几百人的公司把散落在各处的规章制度、报销流程、IT 手册接入 RAG,员工用自然语言就能问"出差住宿标准是多少""怎么申请 VPN"。关键做法:文档按部门加元数据、检索时按提问人权限过滤(避免越权看到不该看的资料)、答案强制附来源链接方便核实。上线后 HR 和 IT 的重复答疑量下降了六成。

案例二:客服 FAQ 机器人。 电商客服场景,把历史工单和 FAQ 文档做成知识库,配合混合检索(用户既会说"我要退货"也会报订单号)。关键做法:低置信度(检索相似度太低)时不硬答,而是转人工,避免一本正经地答错激怒用户;把用户点了"没解决"的问题沉淀下来补充进知识库,形成正循环。

案例三:代码库问答。 面向新入职工程师,把整个代码仓库、架构文档、历史 PR 讨论做成知识库,回答"这个模块负责什么""为什么当初这么设计"。关键做法:按函数/类为单位切块(而非固定字符,保持代码语义完整)、检索时把相关代码和它的文档一起返回。下面是这个场景的一段简化实现,展示按代码结构切块的思路:

\`\`\`python

import ast

def chunk_python_by_function(source_code: str, filename: str):

"""把一个 Python 文件按函数/类切块,每块保留完整定义和所属文件。"""

tree = ast.parse(source_code)

lines = source_code.splitlines()

chunks = []

for node in ast.walk(tree):

if isinstance(node, (ast.FunctionDef, ast.ClassDef)):

start = node.lineno - 1

end = getattr(node, "end_lineno", start + 1)

code = "\n".join(lines[start:end])

chunks.append({

"text": code,

"metadata": {"file": filename, "name": node.name,

"type": type(node).__name__},

})

return chunks

sample = "def add(a, b):\n return a + b\n\nclass Foo:\n pass\n"

for c in chunk_python_by_function(sample, "utils.py"):

print(c["metadata"], "->", c["text"][:30])

\`\`\`

按代码结构(函数、类)而非固定字符切块,能保证检索到的永远是一个完整的、可理解的代码单元,这比机械地每 500 字符切一刀在代码问答场景里效果好得多。这也呼应了前面的原则:切块策略要贴合文档本身的结构。

3.13 RAG 常见坑

| 坑 | 现象 | 解决办法 |

|----|------|---------|

| 构建和检索用了不同 embedding | 检索完全失效 | 两端严格用同一个 embedding 模型 |

| chunk 太大或太小 | 答非所问或信息不全 | 调 chunk_size/overlap,用评测集验证 |

| top_k 一味调大 | 幻觉增加、成本上升 | 3~5 起步,配合 rerank 提精度 |

| 检索不到也硬答 | 一本正经胡说 | 提示"没资料就说不知道"+ 置信度阈值转人工 |

| 不返回来源 | 用户无法核实、不敢信 | 答案附引用编号和来源 |

| 知识库不更新 | 答案越来越过时 | 建立文档增删改同步进向量库的机制 |

3.14 RAG 最佳实践小结

先用 Chroma/FAISS 跑通最小骨架建立直觉;构建和检索用同一 embedding 且贴合语言选型;切块贴合文档结构、带重叠;top_k 3~5 起步、需要精度就加 rerank;企业场景上混合检索;一定要让模型"没资料就说不知道"并返回来源;最重要的是建一个小评测集,让每次调参都有数据支撑。

🗺️ 第四部分:终点亦起点,AI 进阶学习地图

走到这里,你已经具备了调用 API、部署本地模型、搭建 RAG 应用的基本工程能力。但 AI 领域发展速度极快,这更像是一个新的起点。以下是几条继续深入的实用建议。

4.1 如何读懂一篇论文

刚开始读 AI 论文时,逐字逐句从头读到尾往往是最低效的方式,容易在数学推导里迷失,抓不住重点。更实用的顺序是:

先读摘要(Abstract)和结论,快速判断这篇论文解决了什么问题、核心结论是什么,决定是否值得继续深入。
再看图表,尤其是架构图和实验对比表格,论文的核心贡献往往能通过一两张图直观理解,比纯文字描述效率高得多。
然后读引言(Introduction),了解作者是如何定位问题、和已有工作的差异在哪里。
最后才是方法和实验细节,如果需要复现或深入理解技术细节再精读。

常用资源:arXiv 是绝大多数 AI 论文的首发平台(尤其是 cs.CL、cs.LG 分类);Papers with Code 会把论文和对应的开源实现、benchmark 排行榜关联起来,是判断一项技术是否成熟、是否有可用代码的高效途径。刚开始不必追求"读懂每一篇新论文",更现实的目标是先建立一份自己关心方向的"必读综述清单",把综述(Survey)当作地图,再按需深入到具体的原始论文,效率会比漫无目的地刷 arXiv 每日更新高得多。

4.2 值得关注的社区和信息源

Hugging Face:不仅是模型和数据集的托管平台,其 Papers、Spaces、Blog 板块也是了解最新开源模型和应用 demo 的重要窗口。
技术团队博客:各大模型厂商和研究机构的官方博客通常会用比论文更易懂的语言解读自己的最新工作,是论文之外很好的补充读物。
从业者聚集的社交平台(如 X/Twitter 上活跃的研究员和工程师账号):很多重要的进展、讨论甚至"翻车"案例,会比正式论文或博客更早地出现在这里,适合用来保持对行业动态的敏感度。
开源社区和技术论坛:直接参与讨论、提交 issue 或 PR,是从"看别人做"转变为"自己动手做"的有效方式。

4.3 几个可能的深入方向

继续往下走,大致可以分成三条不完全互斥的路径,可以根据自己的兴趣和已有背景来选择:

做应用开发:关注如何把大模型能力包装成好用的产品,重点在于 prompt 设计、RAG 系统优化、Agent 工作流编排、多模型路由、成本和延迟控制。适合有软件工程背景、更关心"落地和体验"的人。
做模型微调:深入理解如何用自己的数据对已有模型做微调(如 LoRA、全参数微调),重点在于数据构造、训练技巧、评估方法。适合对模型内部行为感兴趣、希望针对垂直场景定制模型能力的人。
做底层研究:钻研模型架构、训练算法、对齐技术等更基础的问题,重点在于数学基础、论文阅读与复现能力、实验设计。适合对"为什么模型会这样工作"这类根本问题有强烈好奇心,并愿意投入时间打牢数学和工程基础的人。

判断自己更适合哪条路径,与其空想,不如用一个简单的方法快速试错:分别花一个周末做一件对应方向的小事——用现成 API 拼一个能解决自己实际问题的小工具(应用开发)、跑通一次开源模型的 LoRA 微调教程(模型微调)、精读并复现一篇经典论文里的一个简化实验(底层研究)。做完之后,观察自己在哪个过程里更容易进入专注状态、更愿意主动查资料补漏洞,那大概率就是更值得投入的方向。

三条路径不是彼此排斥的单选题,很多优秀的从业者会在其中两条甚至三条路径之间自由切换。重要的是先动手做出一点小东西——哪怕只是一个跑通的聊天机器人或者一个能回答自己笔记内容的 RAG demo——让学习建立在实践反馈之上,而不是停留在纯粹的阅读和收藏之中。

🧭 全文小结

本文从"打第一个 API 请求"一路走到"搭一个可评估的 RAG 系统",覆盖了大模型工程落地最核心的三块能力。把它们放在一起对比,能更清楚各自的定位和取舍:

| 能力板块 | 核心工具 | 解决的问题 | 主要成本 | 何时选它 |

|---------|---------|-----------|---------|---------|

| 云端 API 调用 | OpenAI 兼容 SDK | 零门槛用上最强模型 | 按 token 付费 | 快速起步、追求效果、无隐私顾虑 |

| 本地部署 | Ollama / llama.cpp / vLLM | 数据不出内网、边际零成本 | 硬件投入、效果折中 | 隐私合规、高频大批量、离线 |

| RAG 知识库 | Chroma / FAISS + rerank | 让模型答私有/最新知识、抗幻觉 | 工程搭建、持续维护 | 需要基于自有文档问答 |

三块能力还有一条清晰的组合逻辑:用 RAG 解决"知识",用本地部署解决"隐私和成本",用 API 的各种工程技巧(重试、并发、结构化输出、函数调用)解决"稳定和能力扩展"。真实的生产系统往往是三者的组合——比如律所案例就是"本地部署 + RAG",客服案例是"API + RAG + 混合检索"。

最后再强调一遍本文从头到尾的那条主线:AI 工程的进步来自动手,而非收藏。这篇教程里的每一段代码都建议你真正敲一遍、跑一遍、改坏再修好。当你亲手让一个模型在自己的机器上跑起来、亲手让它准确回答出一个只有你的文档里才有的答案时,那种"原理真正变成能力"的踏实感,是任何阅读都替代不了的。从今天就开始,挑本文里任意一段代码跑起来吧。