深入 LangChain JS 可控写作实验:解析 temperature、提示词与异步调用机制
第一次为大型语言模型设置参数 temperature: 0.8 时,许多开发者会下意识地将其理解为“将创造力调节至 80%”。这个形象化的说法虽然便于记忆,但本质上并不准确。温度参数并不会为模型注入额外知识,也无法确保输出更具创意。它真正影响的是模型在对候选 token 进行采样时的概率分布形态。

本文将借助一个可直接运行的小型实验,完整构建两条写作链:它们接收相同的主题与相同的提示词,唯一区别就是温度设置。通过这个过程,我们将清晰看到提示词如何进入模型、pipe() 如何串联处理步骤,以及 await chain.invoke() 究竟在等待什么。
首先厘清“模型如何挑选下一个 token”
大型语言模型并非先构思好整段文字再一次性输出。在生成过程中,它会根据已有的上下文反复预测下一个 token。token 是模型处理文本的基本单元,可能是一个汉字、一个词的一部分,或者一个标点符号。
假设模型在“秋天的晚风很”之后给出了几个候选 token。为了便于理解,以下数字仅为示意:
轻柔0.55清凉0.25安静0.12滚烫0.08
这些概率构成一个分布,总和为 1。程序通常不会每次都选择概率最高的 token,而是按照该分布进行采样。因此,即使输入相同的提示词,多次执行的结果也可能不同。
temperature 参数会在采样前对分布进行调整:
- 温度较低时,分布更加集中,高概率候选更容易被选中,输出通常更为稳定、保守。
- 温度较高时,分布趋于平坦,原本概率较低的候选有更多机会出现,输出通常更加多样,但跑题和事实错误的风险也随之增加。
因此,低温并不等同于“绝对正确”,高温也不意味着“必然有创意”。模型已有的知识、提示词质量、上下文以及服务商的采样实现都会影响最终结果。不同 API 对温度的允许范围也可能不同,不应将其固定理解为 0~1 区间。
另一个常见参数是 top_k:它先保留概率最高的 K 个候选,再从中采样。不过,并非所有 OpenAI 兼容接口都支持 top_k。LangChain 的某个模型封装接受该字段,并不代表上游服务一定会使用它。为了让下面的例子适用于更多兼容接口,我们只比较 temperature。如果服务商支持 top_k 或 top_p,应以它的接口文档为准,并尽量一次只调整一个采样参数,否则很难判断究竟是哪个参数导致了变化。
准备一个最小项目
本文使用 Node.js 20 或更高版本。新建目录后安装三个依赖:
npm init -ynpm install @langchain/core @langchain/openai dotenv
它们各司其职:
@langchain/openai提供ChatOpenAI,用于访问 OpenAI API 或兼容接口。@langchain/core提供提示词模板、输出解析器等通用组件。dotenv将.env中的配置加载到process.env。
创建 .env,注意不要将真实密钥提交到 Git:
LLM_API_KEY=your_api_keyLLM_MODEL=your_model_nameLLM_BASE_URL=https://your-provider.example/v1
LLM_MODEL 必须填写服务商真实提供的模型 ID。LLM_BASE_URL 需要指向兼容接口的 API 根地址;不同服务商是否包含 /v1 并不统一。如果直接使用 OpenAI,也可以采用其官方环境变量和默认地址,并相应简化初始化配置。
将程序保存为 main.mjs。.mjs 告诉 Node.js 这是一个 ES 模块,因此可以直接使用 import。
先创建模型,再将提示词从代码中抽离
模型对象保存的不仅是“模型名称”,还包含请求鉴权、接口地址和生成参数。先编写一个创建模型的函数,避免两套配置重复:
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 只负责为输出设置上限。
接下来定义提示词:
import { PromptTemplate } from "@langchain/core/prompts";const storyPrompt = PromptTemplate.fromTemplate("请围绕{theme}写一篇约300字的短篇散文。" +"风格温柔、克制,有具体画面,不要分段。");
{theme} 是占位符。提示词模板的意义不只是减少字符串拼接:它明确了调用方必须提供哪些输入,也使固定规则和动态数据分离。稍后传入 { theme: "秋日山野晚风" } 时,模板才会被格式化为真正发送给模型的文本。
用 pipe 组装两条处理链
聊天模型返回的通常是一个消息对象,其中除了正文还可能包含响应元数据。若后续代码只需要字符串,可以添加 StringOutputParser:
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() 可以理解为连接流水线,但它并不会立即执行请求。每条链都定义了三步数据转换:
{ theme: "..." }↓PromptTemplate:生成完整提示词↓ChatOpenAI:调用模型并得到消息对象↓StringOutputParser:取出文本内容
前一步的输出会成为后一步的输入。这样,调用代码不必自行拆解消息对象,链最终返回的就是普通字符串。
完整可运行代码
下面加入环境变量校验、顺序调用和统一错误处理。为了让实验更容易观察,两次请求使用同一个主题,并且顺序执行,终端输出不会交错。
// main.mjsimport "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;});
运行命令:
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: "秋风" },通常会收到缺少变量的错误。正确写法是让两个名字严格一致:
const text = await creativeChain.invoke({ theme: "秋风" });
对象中的 theme 是属性名,字符串 "秋风" 是属性值。这里也使用了对象属性简写:若已有变量 const theme = "秋风",那么 { theme } 等价于 { theme: theme }。
忘记等待 Promise
下面拿到的不是最终文章,而是一个 Promise:
const text = creativeChain.invoke({ theme: "秋风" });console.log(text);
应在 async 函数中等待它:
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),这会把密钥写入终端或日志。更安全的方式是只输出布尔值:
console.log("是否读取到密钥:", Boolean(process.env.LLM_API_KEY));
参数被静默忽略
一个接口接受 temperature,不代表它也支持 topK;有的推理模型甚至会忽略或限制温度。遇到“改了参数但输出没有明显变化”时,应检查服务商文档、响应警告和实际请求体,再做多轮对照测试。不要仅凭一次生成结果下结论。
把演示升级成可靠实验
当前程序适合学习数据流,但还不是严谨的模型评测。可以沿着三个方向继续改进。
第一,控制变量并重复采样。保持模型、提示词和主题不变,每个温度运行 10 次,保存结果。除了主观阅读,还可以记录格式遵循率、事实错误数和重复表达比例。这样得到的是趋势,而不是一次抽样带来的错觉。
第二,根据任务选参数,而不是按标签套数值。创意写作可以尝试较高温度;信息抽取、分类和代码修改通常从较低温度开始。但只要任务依赖事实,就还需要可靠上下文、检索、校验或测试。RAG 能为模型提供外部资料,却不能承诺彻底消除幻觉。
第三,补上工程保护。生产环境应设置请求超时和重试策略,记录不含密钥的错误上下文,并限制并发与成本。如果返回内容必须是 JSON,不要只在提示词里写“请返回 JSON”,应优先使用模型支持的结构化输出能力,再用 Schema 校验结果。
总结
temperature 控制的是候选 token 概率分布的形状,不是知识量、正确率或创造力的百分比。LangChain 的价值则体现在流程组合:PromptTemplate 把动态输入变成提示词,ChatOpenAI 完成异步模型调用,StringOutputParser 再把消息对象变成业务代码容易使用的字符串。
真正有效的参数调试,需要固定其他条件、重复运行并按任务指标比较。先弄清数据如何穿过整条链,再讨论“哪个温度最好”,会比记住一组看似通用的推荐值更可靠。
