1. 为什么说 Python 是 LLM 时代的“母语”
1.1 从“调 API”到“编排工作流”的认知转变
很多人第一次接触大语言模型,脑子里想的都是“怎么调接口”。写个requests.post,把 prompt 塞进去,拿回一段文本,打印出来,完事。这个阶段我称之为“玩具阶段”。真正把 LLM 融入开发工作流,你会发现核心难点根本不在调接口,而在于怎么把模型的输出变成程序能稳定消费的结构化数据,怎么让多步调用之间形成可靠的依赖关系,怎么在模型抽风的时候兜底。
Python 在这个场景里的地位,类似于当年 Web 开发之于 JavaScript。不是说别的语言不能做,而是生态密度完全不在一个量级。你需要的 HTTP 客户端、异步调度、数据校验、向量计算、文本处理、可视化调试,Python 全都有现成的轮子,而且大部分 LLM 工具链的首发支持都是 Python。这就意味着你踩坑的时候,搜到的解决方案大概率是 Python 的。
我见过不少从 Java 或 Go 转过来做 LLM 应用的开发者,最大的不适应是“动态类型的松散感”。但恰恰是这种松散,让 prompt 模板的迭代、输出结构的试错变得极其轻快。你不需要为了改一个字段定义去重新编译,改完直接跑,错了再改。在 LLM 应用这种“需求每天都在变”的领域,迭代速度就是生命线。
1.2 一个最小可用的 LLM 调用骨架长什么样
先别急着上框架。我建议每个初学者都手写一遍最原始的调用逻辑,哪怕只有二十行。这样你才能理解后面那些框架到底帮你封装了什么。
import os from openai import OpenAI client = OpenAI(api_key=os.environ.get("LLM_API_KEY"), base_url=os.environ.get("LLM_BASE_URL")) def ask(prompt: str, system: str = "你是一个严谨的助手") -> str: resp = client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": system}, {"role": "user", "content": prompt}, ], temperature=0.3, ) return resp.choices[0].message.content这段代码里藏着几个新手最容易忽略的点。第一,api_key和base_url走环境变量,不要硬编码在脚本里,否则你迟早会把密钥提交到仓库。第二,temperature设成 0.3 而不是默认值,是因为大部分工程场景要的是稳定复现,不是创意发散。第三,system消息单独抽出来,方便后续做 prompt 版本管理。
提示:如果你用的是国内模型服务,
base_url通常需要指向对应的兼容端点。不同厂商的字段名可能略有差异,但messages数组的结构基本是通用的。
1.3 环境准备:别在安装环节浪费一整天
Python 安装本身没什么好说的,官网下载对应系统的安装包,Windows 记得勾选“Add Python to PATH”。真正容易出问题的是虚拟环境和依赖版本冲突。
我的习惯是每个 LLM 项目都单独建虚拟环境,用venv就够了,不需要一上来就上conda。命令很简单:
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install --upgrade pip然后装依赖的时候,一定要锁版本。LLM 相关的库更新极快,今天能跑的代码下周可能就因为某个依赖的大版本升级而报错。我通常会在requirements.txt里写死主版本号,比如openai>=1.0,<2.0,而不是裸写openai。
还有一个坑是numpy和cv2这类科学计算库的安装。如果你只是做文本处理,其实用不到cv2;但如果你要做多模态,涉及图像预处理,那opencv-python的安装在某些系统上需要额外的系统级依赖。遇到ImportError先别慌,八成是缺了底层库,搜一下报错信息基本都能解决。
2. 把 LLM 塞进工作流:从脚本到可维护的模块
2.1 为什么你的第一个 LLM 脚本一定会变成“面条代码”
我见过太多人写 LLM 应用的轨迹:一开始是一个main.py,里面塞了 prompt、调用逻辑、结果解析、文件读写。跑通了,很开心。然后需求来了——“能不能把结果存成 JSON”“能不能换个模型试试”“能不能加个重试”。于是main.py膨胀到五百行,函数之间互相调用,改一处崩三处。
这不是能力问题,是结构问题。LLM 应用的本质是“数据在多个处理阶段之间流动”,每个阶段都可能失败、都可能需要重试、都可能需要替换实现。如果你不在一开始就把这些阶段拆开,后面就是在泥潭里打滚。
我的做法是至少拆成四层:配置层、调用层、解析层、编排层。配置层管模型参数和密钥;调用层只管发请求和收响应;解析层负责把自然语言输出变成结构化数据;编排层决定先调哪个后调哪个。这样任何一层出问题,你都能快速定位,而不是在一堆print里大海捞针。
2.2 用 Pydantic 把“模型输出”变成“程序能信的数据”
LLM 最让人头疼的地方是输出不稳定。你让它返回 JSON,它可能给你包一层 markdown 代码块,可能多一句“好的,以下是结果”,可能字段名拼错。如果你直接json.loads,失败率会让你怀疑人生。
这时候Pydantic就是救星。它的思路是:你先定义好期望的数据结构,然后让模型按照这个结构输出,最后用 Pydantic 做校验和容错。
from pydantic import BaseModel, Field, ValidationError import json, re class TaskItem(BaseModel): title: str = Field(description="任务标题") priority: int = Field(ge=1, le=5, description="优先级1-5") tags: list[str] = Field(default_factory=list) def parse_llm_json(raw: str) -> TaskItem | None: # 去掉可能的 markdown 包裹 cleaned = re.sub(r"^```(json)?|```$", "", raw.strip(), flags=re.MULTILINE).strip() try: data = json.loads(cleaned) return TaskItem(**data) except (json.JSONDecodeError, ValidationError) as e: print(f"解析失败: {e}") return None这段代码的关键在于清洗和校验分离。先用正则把常见的 markdown 包裹去掉,再尝试解析,最后用 Pydantic 校验字段类型和范围。任何一步失败都返回None,让上层决定是重试还是降级。
注意:不要指望模型每次都返回完美 JSON。实测下来,即使你在 prompt 里强调“只返回 JSON”,仍有大约 5% 到 15% 的概率会带上额外文字。所以清洗逻辑是必需品,不是可选项。
2.3 重试机制:不是“再试一次”那么简单
“调用失败就重试”听起来很合理,但如果你无脑重试,可能会遇到三个问题:重复计费、雪崩式超时、以及把临时错误当成永久错误。
我的重试策略遵循三条规则。第一,只对可恢复错误重试,比如网络超时、限流返回 429,而参数错误、认证失败这种重试一万次也没用。第二,重试要指数退避,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,避免把服务端打垮。第三,设置最大重试次数,通常 3 次就够了,超过就抛异常让上层处理。
import time, random def call_with_retry(fn, max_retries=3, base_delay=1.0): for attempt in range(max_retries): try: return fn() except Exception as e: if attempt == max_retries - 1: raise delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5) print(f"第{attempt+1}次失败,{delay:.1f}秒后重试: {e}") time.sleep(delay)这里加了一个随机抖动,是为了避免多个并发请求同时重试造成“惊群效应”。这个细节在单机脚本里无所谓,但一旦你部署成服务,就是能不能扛住压力的关键。
3. 提示词工程不是“玄学”,是可复现的工程实践
3.1 把 prompt 当代码管理:版本、变量、测试
我见过团队把 prompt 直接写在业务代码的字符串里,改一次就要发一次版。这是典型的“把 prompt 当配置”的反面教材。正确的做法是把 prompt 当成有版本的资产,单独存放,单独测试。
我的习惯是用模板文件加变量注入的方式。比如建一个prompts/目录,里面放summarize_v1.txt、summarize_v2.txt,每个文件里用{input_text}这样的占位符。代码里读取文件、填充变量、发送请求。这样改 prompt 不需要动代码,而且可以对比不同版本的效果。
更重要的是给 prompt 写测试。不是测“输出对不对”,而是测“输出是否符合结构约束”。比如你要求模型返回三个要点,那就写个测试断言len(result.points) == 3。这样当你调整 prompt 时,能立刻知道有没有破坏原有约束。
3.2 少样本示例的摆放位置比数量更重要
很多人以为 few-shot 就是“多给几个例子”,于是塞了十个示例进去,结果 token 消耗暴涨,效果却没提升多少。实测下来,示例的质量和位置比数量关键得多。
我的经验是:两到三个高质量示例足够。示例要覆盖边界情况,而不是重复同类。比如做情感分类,不要给三个“正面”示例,而要给一个正面、一个负面、一个中性或模糊的。这样模型才能学到区分边界,而不是简单模仿。
摆放位置上,示例放在 system 消息之后、用户输入之前,效果通常最稳。有些模型对示例的格式敏感,比如用输入:... 输出:...这种明确分隔,比纯自然语言描述更可靠。
3.3 温度、top_p 这些参数到底该怎么调
新手最容易犯的错是“所有场景都用默认参数”。但不同任务对参数的需求完全不同。
| 任务类型 | temperature | top_p | 说明 |
|---|---|---|---|
| 结构化抽取 | 0.0 - 0.2 | 0.1 | 要的是稳定复现,不要创意 |
| 代码生成 | 0.2 - 0.4 | 0.3 | 需要一定灵活性但别太飘 |
| 文案创作 | 0.7 - 0.9 | 0.9 | 要多样性,允许发散 |
| 头脑风暴 | 0.9 - 1.2 | 0.95 | 越意外越好 |
这张表不是铁律,但能帮你建立直觉。核心逻辑是:temperature 控制随机性,top_p 控制候选范围。两者不要同时调高,否则输出会失控。我通常固定一个、调另一个,这样变量可控。
还有一个容易被忽略的参数是max_tokens。设太小会截断,设太大会浪费。我的做法是先跑几次观察典型输出长度,然后设成典型长度的 1.5 倍作为上限。
4. 当 LLM 开始“胡说”:容错与验证的工程手段
4.1 幻觉不是 bug,是特性,你得学会和它共处
“模型胡说八道怎么办”是每个新手都会问的问题。我的回答是:你不可能完全消除幻觉,但你可以设计系统让幻觉不造成伤害。
思路有三条。第一,关键事实必须外部验证。模型说“这个函数返回字符串”,你就写个测试跑一下,别信它。第二,让模型给出置信度或来源。虽然模型的自我评估也不完全可靠,但至少能帮你筛选出高风险输出。第三,设计降级路径。当模型输出无法解析或验证失败时,系统应该能回退到规则引擎或人工审核,而不是直接崩溃。
我做过一个信息抽取项目,模型对某些字段的抽取准确率只有 70% 左右。我的处理方式是:高置信字段自动入库,低置信字段标记待审核,完全无法解析的走人工。这样整体准确率上去了,人工成本也可控。
4.2 用“自洽性检查”过滤掉明显不靠谱的输出
一个简单但有效的技巧是:让模型对同一个输入生成多次输出,然后看它们是否一致。如果三次输出差异巨大,说明这个输入可能本身模糊,或者模型不确定,这时候就应该触发人工介入。
def self_consistency(prompt, n=3): results = [ask(prompt) for _ in range(n)] # 简单的一致性判断:看结果是否高度相似 from difflib import SequenceMatcher base = results[0] similarities = [SequenceMatcher(None, base, r).ratio() for r in results[1:]] avg_sim = sum(similarities) / len(similarities) return base, avg_sim # 相似度低于阈值就标记为不可靠 answer, confidence = self_consistency("提取这段文本中的日期") if confidence < 0.6: print("输出不稳定,建议人工复核")这个方法会增加调用成本,所以只用在关键字段上。但实测下来,它对过滤“模型瞎猜”的情况非常有效。
4.3 日志与可观测性:出问题时你能查到什么
LLM 应用最痛苦的时候是“线上效果变差了,但不知道哪里变了”。可能是模型服务商悄悄更新了模型,可能是你的 prompt 被谁改了一行,可能是输入数据的分布变了。
所以从第一天起就要记录完整的调用日志:时间戳、模型名、prompt 全文、参数、原始输出、解析后结果、耗时、token 消耗。这些数据在排查问题时就是你的“黑匣子”。
我通常会把日志写到结构化文件里,比如 JSONL 格式,每行一条记录。这样后续可以用pandas或jq做分析,比如统计不同 prompt 版本的成功率、平均耗时、token 成本。没有这些数据,优化就是拍脑袋。
5. 从单机脚本到可复用模块:工程化的必经之路
5.1 什么时候该引入框架,什么时候不该
市面上的 LLM 框架很多,但我建议初学者先别急着上框架。原因很简单:框架帮你封装了细节,但也屏蔽了细节。当你遇到问题时,如果不理解底层发生了什么,排查会非常困难。
我的判断标准是:当你发现自己反复在写同样的重试逻辑、同样的解析逻辑、同样的日志逻辑时,就该考虑抽象了。但抽象不一定是引入第三方框架,也可以是自己写一个薄薄的封装层。自己写的封装层你完全掌控,出问题好改;第三方框架功能全但学习成本和调试成本都高。
如果确实要用框架,选那种轻量、透明、可替换的。太重、太“全家桶”的框架,一旦你想换个模型或换个存储,迁移成本会让你后悔。
5.2 一个可复用的 LLM 客户端应该长什么样
我理想中的 LLM 客户端模块,对外只暴露几个简单方法,内部把复杂度全吃掉。
class LLMClient: def __init__(self, model: str, api_key: str, base_url: str, timeout: int = 30): self.model = model self.timeout = timeout self._client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout) def complete(self, prompt: str, system: str = "", temperature: float = 0.3) -> str: """纯文本补全,返回字符串""" ... def complete_json(self, prompt: str, schema: type[BaseModel], system: str = "") -> BaseModel | None: """结构化输出,返回 Pydantic 对象或 None""" ... def complete_batch(self, prompts: list[str], concurrency: int = 5) -> list[str]: """批量调用,内部处理并发和限流""" ...这个设计的核心是按输出类型分方法,而不是按底层 API 分。调用方不需要关心底层是 chat 还是 completion,只需要关心“我要文本”还是“我要结构化数据”。这样后续换模型、换服务商,调用方代码基本不用动。
5.3 并发调用:快是快,但坑也不少
批量处理时,串行调用太慢,并发是必然选择。但并发调用 LLM 接口有几个坑:限流、超时、以及结果顺序。
限流方面,大部分服务商都有 RPM(每分钟请求数)和 TPM(每分钟 token 数)限制。你并发太高会被拒,所以需要信号量或令牌桶来控制速率。超时方面,并发时单个请求超时可能拖垮整个批次,所以每个请求都要有独立超时。结果顺序方面,如果你用asyncio.gather,返回顺序和输入顺序一致;但如果用线程池,就要自己维护索引映射。
import asyncio from asyncio import Semaphore async def batch_complete(prompts, client, concurrency=5): sem = Semaphore(concurrency) async def one(p): async with sem: return await asyncio.to_thread(client.complete, p) return await asyncio.gather(*[one(p) for p in prompts])这个模式我用了很多次,稳定可靠。Semaphore控制并发数,asyncio.to_thread把同步调用包成异步,gather保证顺序。简单但够用。
6. 初学者最容易踩的五个坑
6.1 把密钥写进代码然后提交到仓库
这是最致命也最常见的错误。一旦密钥泄露,别人可以用你的额度,账单可能高得吓人。解决方案很简单:环境变量加.gitignore。在项目根目录建.env文件,把密钥写进去,然后在.gitignore里加上.env。代码里用os.environ.get读取。如果已经提交了,立刻去服务商后台吊销旧密钥,重新生成。
6.2 不设超时,一个请求卡死整个程序
LLM 接口偶尔会响应很慢,如果你不设超时,程序可能卡在那里几分钟甚至更久。我的习惯是所有网络调用都设超时,通常 30 秒。超时后走重试逻辑,而不是无限等待。
6.3 忽略 token 消耗,月底账单吓一跳
token 是钱。长 prompt、长输出、频繁调用,都会让成本快速上升。我的做法是在日志里记录每次调用的 token 数,定期统计。如果发现某个 prompt 消耗异常,就优化它。另外,能用小模型解决的场景就别用大模型,成本差可能是十倍以上。
6.4 直接信任模型输出,不做任何校验
模型会犯错,而且犯错时往往很自信。任何进入下游系统的数据,都必须经过校验。校验可以是类型检查、范围检查、或者业务规则检查。宁可多写几行校验代码,也不要让脏数据流进数据库。
6.5 不写测试,改一行 prompt 就心里没底
LLM 应用的测试和传统软件不同,不能断言“输出等于某个固定值”。但可以断言结构约束和关键属性。比如“输出必须是合法 JSON”“必须包含三个字段”“优先级必须在 1 到 5 之间”。这些测试能帮你在迭代 prompt 时快速发现回归。
7. 下一步该往哪里走
如果你已经跟着上面的内容跑通了一个最小可用的 LLM 调用,并且把它封装成了可复用的模块,那你的基础已经打好了。接下来可以往几个方向深入。
一个是检索增强生成,也就是常说的 RAG。核心思路是先把相关知识存进向量库,调用时先检索再生成,让模型的回答有据可依。另一个是多步推理与工具调用,让模型不仅能回答问题,还能调用外部函数、查询数据库、执行操作。还有一个方向是评估与优化,建立一套自动化的评估流程,持续监控模型表现,及时发现退化。
但不管往哪个方向走,底层的能力是一样的:把 LLM 当成一个不稳定的、需要被编排的组件,而不是一个魔法黑盒。你越早建立这个认知,后面的路就越顺。
我在实际项目里最大的体会是,LLM 应用的难点从来不在模型本身,而在工程。模型能力是给定的,但怎么组织代码、怎么处理失败、怎么验证输出、怎么控制成本,这些才是决定项目成败的关键。Python 给了你足够的灵活性去应对这些挑战,剩下的就是动手去踩坑、去总结、去迭代。