用 LangChain JS 做可控写作实验:理解温度参数、提示词与异步调用

用 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_ktop_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 开始,程序究竟怎样执行

以第一条链为例,完整过程如下:

  1. Node.js 加载三个模块,并由 dotenv/config.env 写入 process.env
  2. 顶层代码检查三个必要配置;缺少任意一项就立即抛出错误,不会发起无意义的网络请求。
  3. 程序创建提示词模板、解析器和两条链。此时只是准备对象,并未生成文章。
  4. runWritingExperiment() 被调用,局部变量 input 保存参数对象 { theme: "秋日山野晚风" }
  5. creativeChain.invoke(input) 启动第一条链。模板从对象中读取与占位符同名的 theme,生成完整提示词。
  6. ChatOpenAI 使用模型名、密钥和接口地址发起 HTTP 请求。这个异步操作返回 Promise,它表示"未来才会得到的结果"。
  7. 因为 runWritingExperiment 声明为 async,函数内部可以使用 awaitawait 暂停的是当前异步函数后续语句;JavaScript 运行时仍可处理其他任务,并不是整个进程被冻结。
  8. 请求成功后,模型消息进入 StringOutputParser,解析器取出文本并把字符串作为整条链的结果。
  9. creativeText 接收这个字符串并输出。随后第二条链才以 0.2 的温度执行相同过程。
  10. 如果请求失败,invoke() 返回的 Promise 会被拒绝。异常沿着 await 传播,使 runWritingExperiment() 返回一个被拒绝的 Promise,末尾的 .catch() 负责打印错误并设置非零退出码。

注意,invoke() 的参数必须是对象,因为模板要按属性名查找变量。把主题直接写成字符串会让模板找不到 theme

常见错误与排查方法

模型不存在或接口地址不匹配

常见现象是 404model not found,或者服务商返回"模型不存在"。模型 ID 不能凭产品名称猜测,兼容 OpenAI 的请求格式也不意味着模型名相同。

排查时依次确认:

  1. .env 中的 LLM_MODEL 是否是账号实际可用的模型 ID。
  2. LLM_BASE_URL 是否为 API 地址,而不是服务商官网首页。
  3. 地址是否需要 /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 再把消息对象变成业务代码容易使用的字符串。

真正有效的参数调试,需要固定其他条件、重复运行并按任务指标比较。先弄清数据如何穿过整条链,再讨论"哪个温度最好",会比记住一组看似通用的推荐值更可靠。

相关推荐
爱勇宝3 小时前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
用户208046804563 小时前
Python3 条件控制新手实战指南
后端
观远数据3 小时前
Excel到数据资产池:文件数据入湖的治理规范怎么建
前端·javascript·excel
CoderWeen3 小时前
我写了个能一步步点着看的 Dijkstra 可视化项目(Vue3 + Leaflet + Generator)
前端·javascript·vue.js
Anova.YJ3 小时前
AI通用能力(二)
人工智能
m沐沐3 小时前
【深度学习】卷积神经网络 数据增强、保存最优模型实现,详细解读
人工智能·python·深度学习·机器学习·cnn·数据增强
rain_sxr3 小时前
把多步串起来:Agent 前端编排的状态机与进度可视化
人工智能
HONG````3 小时前
HarmonyOS ArkUI RelativeContainer 相对定位布局:ID 锚点与 alignRules 实战
后端
Conan在掘金4 小时前
�鸿蒙报错速查:Cannot find name 'AppStorageV2',忘 import 编译就炸,根因 + 眜解法
后端