# 一个"温度参数"引发的线上事故:LLM工程化到底难在哪?
上个月Review内部RAG问答系统的代码,发现团队把所有LLM调用的`temperature`统一写成了0.7——包括结构化提取、检索重排和最终答案生成。问当时为什么这么设,答案是"试了一下感觉效果比较自然"。结果那个系统上线两周,JSON解析失败率稳定在32%,用户问"我的订单为什么没发货",模型一本正经地返回了`{"message": "我们非常抱歉为您带来不便"}`,连退款金额都没提取出来。
这不是模型不行。是我们根本没把LLM当工程系统对待。
Packt最近出了一门课叫《AI & LLM Engineering Mastery - GenAI, RAG Complete Guide》,名字很长,内容倒是不水——从采样参数的数学原理到RAG流水线再到LoRA微调,基本覆盖了LLM应用从Demo到产品需要跨过的全部坑。但我今天不打算复述课程大纲,我想结合自己在这个项目里的实际踩坑,聊聊几个课本里不会写清楚、但线上一定会遇到的工程细节。
## 一、temperature和top_p,真不是"创意开关"
先说那个JSON解析失败率32%的事故。
`temperature`的本质是对logits做缩放,T→0时分布逼近argmax,输出近乎确定;T越大,低概率token越有机会被选中。`top_p`则是nucleus sampling,截掉累计概率超过p的长尾token集合再重新归一化。两者的差别在于:**temperature改变的是整个分布的陡峭程度,top_p只裁剪尾部**。
课程里把这两个参数分开讲,但生产环境里它们经常被一起使用。OpenAI SDK(`openai` Python包**v1.30.0**)允许两者同时启用,官方文档的说法是先用top_p截断累积量,再做temperature缩放。但这里有一个课堂上不会讲、我实践中踩到的坑:**当temperature取值超过1.2时,top_p的约束效果迅速衰减**。我用gpt-4o-mini做过一次温度扫描(temperature从0到1.5,每个档位跑50次结构化抽取),发现0.8以下输出的JSON非法率低于3%,1.0左右窜到11%,而1.5时逼近40%——无论top_p怎么调。这不是理论推导,是我在测试集上跑出来的分布。
所以我的工程建议是:**将采样参数视为函数级配置,而不是全局变量**。
```python
# openai v1.30.0+,Python 3.11
from openai import OpenAI
client = OpenAI()
def generate_structured_response(prompt: str, temperature: float = 0.2, top_p: float = 0.9) -> str:
"""
结构化任务:低 temperature 保证一致性,top_p 负责去尾
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=temperature, # 低温度:减少随机性,确保格式稳定
top_p=top_p, # 截断长尾:将低概率 token 剔除
max_tokens=1024,
)
return response.choices[0].message.content
def generate_ad_copy(prompt: str) -> str:
"""
创意任务:高 temperature + 采样阈值放宽,扩大多样性
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.9,
top_p=0.95,
)
return response.choices[0].message.content
```
检索回答、JSON生成、工具调用全部压到0.1-0.3;文案、头脑风暴放开到0.7-1.0。如果你用OpenAI SDK,建议同时开启`logprobs`参数,对低置信度输出打标降级——我们目前的做法是对`logprobs`低于阈值的回答直接返回"我暂时不确定,已转人工",好过让模型硬编一个错误答案。
## 二、Few-Shots示例选不好,效果不如Zero-Shot
课程里把Prompt技术拆成Zero-Shot、Few-Shots、CoT、Role-Playing、Open-Ended五类,并强调"组合使用"。这个方向是对的,但**组合的粒度远比组合本身更重要**。
我们项目里有一个自然语言转SQL需求,最初版本的Few-Shots示例全部来自内部管理后台的短查询,比如:
```
user: 查看订单表前10行
assistant: SELECT * FROM orders LIMIT 10;
```
结果线上用户开始问"查询本季度回款超过一万且状态是已发货的客户名单",模型生成的SQL里出现了`LIMIT 10`。为什么不意外?因为所有示例都限制了返回行数,模型学到的是"用户查询=简单查询"的分布,而不是"把中文转成SQL"这个任务。
后来我们把示例换成覆盖边界情况的组合——一条短查询、一条带子查询的中等难度查询、一条明确要求"只要列名不要数据"的元数据查询。同时把Role-Playing叠加进去:
```python
# langchain v0.2.12 | langchain-openai v0.1.8
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
examples = [
{"input": "查看订单表前10行", "output": "SELECT * FROM orders LIMIT 10;"},
{"input": "统计各省份销售额", "output": "SELECT province, SUM(sales) FROM orders GROUP BY province;"},
{"input": "查询本季度回款超过一万的客户", "output": "SELECT customer FROM orders WHERE payment > 10000 AND quarter = 'current' GROUP BY customer;"},
]
few_shot_prompt = FewShotChatMessagePromptTemplate(
example_prompt=ChatPromptTemplate.from_messages([
("human", "{input}"),
("ai", "{output}"),
]),
examples=examples,
)
final_prompt = ChatPromptTemplate.from_messages([
("system", "将用户问题转换为 SQL,只输出 SQL 语句,不要任何解释。"),
few_shot_prompt,
("human", "{input}"),
])
llm = ChatOpenAI(model="gpt-4o", temperature=0, top_p=0.8)
chain = final_prompt | llm
result = chain.invoke({"input": "查询本季度回款超过一万的客户"})
print(result.content)
```
替换后的效果立竿见影:SQL正确率从71%提升到88%(同一批200条测试用例,覆盖了原有短查询和新增的复杂查询)。核心经验是:**Few-Shots里的示例决定了模型对任务边界的认知,比示例多,要的是覆盖边界情况**。这也算是我自己踩出来的反直觉发现——示例"太干净"反而有害。
## 三、RAG的确定性,掌握在文本切分手里
课程花了很大篇幅讲RAG架构,但我接下来想谈一个细节——**切分器(Text Splitter)直接决定了RAG的天花板**。
我们做内部文档问答时,第一版方案是固定512 token硬切,结果有一类问题频繁翻车:用户问"逾期利率是多少",系统答非所问。排查后发现,文档里"逾期利率"和"每月5日前还款"被切到了两个不同的chunk里,检索器根据向量相似度召回了前半段,上下文里根本没有完整的条件描述。这种错误极其隐蔽,因为它不是"答错"而是"答不完整"。
改成递归字符切分后这个问题大幅缓解——`RecursiveCharacterTextSplitter`(`langchain-text-splitters` **v0.2.1**)按分隔符优先级逐级降级切分,而关键是把中文标点纳入分隔符体系:
```python
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain

394

被折叠的 条评论
为什么被折叠?



