用 LangChain JS 做一次可控写作实验:理解 temperature、提示词与异步调用
第一次给大模型传入 temperature: 0.8 时,很多人会把它理解成"把创造力调到 80%"。这个说法好记,却不准确:温度不会给模型增加知识,也不能保证文章更有创意。它改变的是模型从候选 token 中取样时的概率分布。
这篇文章通过一个可以直接运行的小实验完成两条写作链:它们接收相同主题和相同提示词,只使用不同的温度。我们会借此看清提示词如何进入模型、pipe() 如何连接处理步骤,以及 await chain.invoke() 到底等待了什么。
先把"模型如何选择下一个 token"说清楚
大模型并不是先想好整段文字再一次性输出。生成过程中,它会根据已有上下文反复预测下一个 token。token 是模型处理文本的基本单位,可能是一个字、一个词的一部分,也可能是标点。
假设模型在"秋天的晚风很"后面给出了几个候选 token。为了便于理解,下面的数字只是示意:
text
轻柔 0.55
清凉 0.25
安静 0.12
滚烫 0.08
这些概率构成一个分布,总和为 1。程序接下来通常不是永远选择第一名,而是按分布抽样。于是,同一个提示词执行多次,结果也可能不同。
temperature 会在抽样前调整这个分布:
- 温度较低时,分布更集中,高概率候选更容易被选中,输出通常更稳定、保守。
- 温度较高时,分布更平坦,原本概率较低的候选有更多机会出现,输出通常更多样,但跑题和事实错误的风险也会上升。
因此,低温不等于"绝对正确",高温也不等于"必然有创意"。模型已有的知识、提示词质量、上下文和服务商的采样实现都会影响结果。不同 API 对温度的允许范围也可能不同,不应把它固定理解为 0~1。
另一个常见参数是 top_k:它先保留概率最高的 K 个候选,再从中抽样。不过,并非所有 OpenAI 兼容接口都支持 top_k,LangChain 的某个模型封装接受一个字段,也不代表上游服务一定会使用它。为了让下面的例子适用于更多兼容接口,我们只比较 temperature。如果服务商支持 top_k 或 top_p,应以它的接口文档为准,并尽量一次只调整一个采样参数,否则很难判断究竟是谁造成了变化。
准备一个最小项目
本文使用 Node.js 20 或更高版本。新建目录后安装三个依赖:
bash
npm init -y
npm install @langchain/core @langchain/openai dotenv
它们分别承担以下工作:
@langchain/openai提供ChatOpenAI,用于访问 OpenAI API 或兼容接口。@langchain/core提供提示词模板、输出解析器等通用组件。dotenv把.env中的配置加载到process.env。
创建 .env,不要把真实密钥提交到 Git:
env
LLM_API_KEY=your_api_key
LLM_MODEL=your_model_name
LLM_BASE_URL=https://your-provider.example/v1
LLM_MODEL 必须填写服务商真实提供的模型 ID。LLM_BASE_URL 需要指向兼容接口的 API 根地址;不同服务商是否包含 /v1 并不统一。如果直接使用 OpenAI,也可以采用其官方环境变量和默认地址,并相应简化初始化配置。
将程序保存为 main.mjs。.mjs 告诉 Node.js 这是一个 ES 模块,因此可以直接使用 import。
先创建模型,再把提示词从代码中抽出来
模型对象保存的不只是"模型名称",还包含请求鉴权、接口地址和生成参数。先写一个创建模型的函数,避免两套配置重复:
javascript
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
function createModel(temperature) {
return new ChatOpenAI({
model: process.env.LLM_MODEL,
temperature,
maxTokens: 500,
apiKey: process.env.LLM_API_KEY,
configuration: {
baseURL: process.env.LLM_BASE_URL,
},
});
}
const creativeModel = createModel(0.8);
const preciseModel = createModel(0.2);
调用 createModel(0.8) 时,实参 0.8 传给形参 temperature,函数再把它放入配置对象,并返回一个 ChatOpenAI 实例。两个变量因此指向两个配置不同的客户端,但还没有向模型发送请求。
maxTokens 限制模型最多生成多少 token,它不是精确字数。中文字符与 token 通常也不是一一对应,所以"约 300 字"仍要在提示词中表达,maxTokens 只负责给输出设置上限。
接下来定义提示词:
javascript
import { PromptTemplate } from "@langchain/core/prompts";
const storyPrompt = PromptTemplate.fromTemplate(
"请围绕{theme}写一篇约300字的短篇散文。" +
"风格温柔、克制,有具体画面,不要分段。"
);
{theme} 是占位符。提示词模板的意义并不只是少写几次字符串拼接:它明确了调用方必须提供哪些输入,也让固定规则和动态数据分离。稍后传入 { theme: "秋日山野晚风" } 时,模板才会被格式化为真正发送给模型的文本。
用 pipe 组装两条处理链
聊天模型返回的通常是一个消息对象,其中除了正文还可能包含响应元数据。若后续代码只需要字符串,可以添加 StringOutputParser:
javascript
import { StringOutputParser } from "@langchain/core/output_parsers";
const outputParser = new StringOutputParser();
const creativeChain = storyPrompt
.pipe(creativeModel)
.pipe(outputParser);
const preciseChain = storyPrompt
.pipe(preciseModel)
.pipe(outputParser);
pipe() 可以理解为连接流水线,但它并不会立即执行请求。每条链都定义了三步数据转换:
text
{ theme: "..." }
↓
PromptTemplate:生成完整提示词
↓
ChatOpenAI:调用模型并得到消息对象
↓
StringOutputParser:取出文本内容
前一步的输出会成为后一步的输入。这样,调用代码不必自己拆解消息对象,链最终返回的就是普通字符串。
完整可运行代码
下面加入环境变量校验、顺序调用和统一错误处理。为了让实验更容易观察,两次请求使用同一个主题,并且顺序执行,终端输出不会交错。
javascript
// main.mjs
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
const requiredEnvNames = ["LLM_API_KEY", "LLM_MODEL", "LLM_BASE_URL"];
for (const name of requiredEnvNames) {
if (!process.env[name]) {
throw new Error(`缺少环境变量:${name}`);
}
}
function createModel(temperature) {
return new ChatOpenAI({
model: process.env.LLM_MODEL,
temperature,
maxTokens: 500,
apiKey: process.env.LLM_API_KEY,
configuration: {
baseURL: process.env.LLM_BASE_URL,
},
});
}
const storyPrompt = PromptTemplate.fromTemplate(
"请围绕{theme}写一篇约300字的短篇散文。" +
"风格温柔、克制,有具体画面,不要分段。"
);
const outputParser = new StringOutputParser();
function createStoryChain(temperature) {
const model = createModel(temperature);
return storyPrompt.pipe(model).pipe(outputParser);
}
const creativeChain = createStoryChain(0.8);
const preciseChain = createStoryChain(0.2);
async function runWritingExperiment() {
const input = { theme: "秋日山野晚风" };
console.log("--- 较高温度:0.8 ---");
const creativeText = await creativeChain.invoke(input);
console.log(creativeText);
console.log("\n--- 较低温度:0.2 ---");
const preciseText = await preciseChain.invoke(input);
console.log(preciseText);
}
runWritingExperiment().catch((error) => {
console.error("生成失败:", error);
process.exitCode = 1;
});
运行命令:
bash
node main.mjs
这里没有用某一次输出证明"0.8 一定比 0.2 更好"。生成本身带有随机性,更合理的观察方法是每组重复运行多次,再比较用词多样性、跑题比例和要求遵循情况。
从 invoke 开始,程序究竟怎样执行
以第一条链为例,完整过程如下:
- Node.js 加载三个模块,并由
dotenv/config将.env写入process.env。 - 顶层代码检查三个必要配置;缺少任意一项就立即抛出错误,不会发起无意义的网络请求。
- 程序创建提示词模板、解析器和两条链。此时只是准备对象,并未生成文章。
runWritingExperiment()被调用,局部变量input保存参数对象{ theme: "秋日山野晚风" }。creativeChain.invoke(input)启动第一条链。模板从对象中读取与占位符同名的theme,生成完整提示词。ChatOpenAI使用模型名、密钥和接口地址发起 HTTP 请求。这个异步操作返回 Promise,它表示"未来才会得到的结果"。- 因为
runWritingExperiment声明为async,函数内部可以使用await。await暂停的是当前异步函数后续语句;JavaScript 运行时仍可处理其他任务,并不是整个进程被冻结。 - 请求成功后,模型消息进入
StringOutputParser,解析器取出文本并把字符串作为整条链的结果。 creativeText接收这个字符串并输出。随后第二条链才以0.2的温度执行相同过程。- 如果请求失败,
invoke()返回的 Promise 会被拒绝。异常沿着await传播,使runWritingExperiment()返回一个被拒绝的 Promise,末尾的.catch()负责打印错误并设置非零退出码。
注意,invoke() 的参数必须是对象,因为模板要按属性名查找变量。把主题直接写成字符串会让模板找不到 theme。
常见错误与排查方法
模型不存在或接口地址不匹配
常见现象是 404、model not found,或者服务商返回"模型不存在"。模型 ID 不能凭产品名称猜测,兼容 OpenAI 的请求格式也不意味着模型名相同。
排查时依次确认:
.env中的LLM_MODEL是否是账号实际可用的模型 ID。LLM_BASE_URL是否为 API 地址,而不是服务商官网首页。- 地址是否需要
/v1,以及账号所在地域是否使用不同域名。
提示词变量没有传对
模板中使用 {theme},调用时却传入 { topic: "秋风" },通常会收到缺少变量的错误。正确写法是让两个名字严格一致:
javascript
const text = await creativeChain.invoke({ theme: "秋风" });
对象中的 theme 是属性名,字符串 "秋风" 是属性值。这里也使用了对象属性简写:若已有变量 const theme = "秋风",那么 { theme } 等价于 { theme: theme }。
忘记等待 Promise
下面拿到的不是最终文章,而是一个 Promise:
javascript
const text = creativeChain.invoke({ theme: "秋风" });
console.log(text);
应在 async 函数中等待它:
javascript
const text = await creativeChain.invoke({ theme: "秋风" });
console.log(text);
若 Promise 被拒绝而程序又没有 .catch() 或 try...catch,错误可能变成未处理的 Promise rejection,排查信息也会更零散。
密钥明明写了,程序却读取不到
先确认入口顶部存在 import "dotenv/config";,.env 位于执行命令所对应项目的正确位置,并检查变量名是否完全一致。还要注意,环境变量的值都是字符串。
不要用 console.log(process.env.LLM_API_KEY) 排查线上问题,这会把密钥写入终端或日志。更安全的方式是只输出布尔值:
javascript
console.log("是否读取到密钥:", Boolean(process.env.LLM_API_KEY));
参数被静默忽略
一个接口接受 temperature,不代表它也支持 topK;有的推理模型甚至会忽略或限制温度。遇到"改了参数但输出没有明显变化"时,应检查服务商文档、响应警告和实际请求体,再做多轮对照测试。不要仅凭一次生成结果下结论。
把演示升级成可靠实验
当前程序适合学习数据流,但还不是严谨的模型评测。可以沿着三个方向继续改进。
第一,控制变量并重复采样。保持模型、提示词和主题不变,每个温度运行 10 次,保存结果。除了主观阅读,还可以记录格式遵循率、事实错误数和重复表达比例。这样得到的是趋势,而不是一次抽样带来的错觉。
第二,根据任务选参数,而不是按标签套数值。创意写作可以尝试较高温度;信息抽取、分类和代码修改通常从较低温度开始。但只要任务依赖事实,就还需要可靠上下文、检索、校验或测试。RAG 能为模型提供外部资料,却不能承诺彻底消除幻觉。
第三,补上工程保护。生产环境应设置请求超时和重试策略,记录不含密钥的错误上下文,并限制并发与成本。如果返回内容必须是 JSON,不要只在提示词里写"请返回 JSON",应优先使用模型支持的结构化输出能力,再用 Schema 校验结果。
总结
temperature 控制的是候选 token 概率分布的形状,不是知识量、正确率或创造力的百分比。LangChain 的价值则体现在流程组合:PromptTemplate 把动态输入变成提示词,ChatOpenAI 完成异步模型调用,StringOutputParser 再把消息对象变成业务代码容易使用的字符串。
真正有效的参数调试,需要固定其他条件、重复运行并按任务指标比较。先弄清数据如何穿过整条链,再讨论"哪个温度最好",会比记住一组看似通用的推荐值更可靠。