1. 先把调用跑通
这一篇不追求讲很多概念,只做一件事:先把 LangChain 跑起来
我用的是 DeepSeek,但接法走的是 OpenAI 兼容接口,所以代码里会用到 @langchain/openai 的 ChatOpenAI
我们会按三个小步骤往下走:
- 先直接调一次模型
- 再试一次流式输出
- 最后再换成一个最小 Agent
这样走下来,后面再看消息、Prompt、Tool、Agent,就不会觉得突然
2. 先看目录结构
第一次尝试 LangChain,建议不要一上来就塞进页面里。
先单独放一个 playground,更容易定位问题。
当前目录结构是这样的:
3. 依赖和命令
先看当前 playground 里的 package.json:
01{02"name": "langchain-first-call-playground",03"private": true,04"type": "module",05"packageManager": "yarn@4.12.0",06"scripts": {07"first-call": "tsx scripts/first-call.ts",08"first-stream": "tsx scripts/first-stream.ts",09"first-agent": "tsx scripts/first-agent.ts"10},11"dependencies": {12"@langchain/core": "^1.1.36",13"@langchain/openai": "^1.3.1",14"dotenv": "^17.3.1",15"langchain": "^1.2.37"16},17"devDependencies": {18"tsx": "^4.21.0",19"typescript": "^6.0.2"20}21}
这里先记住几个最常用的:
@langchain/openai:负责接 OpenAI 兼容接口langchain:后面写 Agent 会用到dotenv:读取.env.localtsx:直接运行 TypeScript 脚本
4. 先把环境变量配对
当前这套 playground 用的是下面这组三个变量:
1DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx2DEEPSEEK_BASE_URL=https://api.deepseek.com/v13DEEPSEEK_MODEL=deepseek-chat
这三个字段分别对应:
DEEPSEEK_API_KEY:密钥DEEPSEEK_BASE_URL:OpenAI 兼容接口地址DEEPSEEK_MODEL:模型名
这里有个很容易漏掉的细节:
DEEPSEEK_BASE_URL 要带上 /v1。
如果少了这一段,脚本很容易直接报错。
5. 第一次完整调用:first-call.ts
第一步先不要碰 Agent,直接调模型。
01import dotenv from 'dotenv'02import { ChatOpenAI } from '@langchain/openai'0304dotenv.config({ path: new URL('../.env.local', import.meta.url) })0506const model = new ChatOpenAI({07apiKey: process.env.DEEPSEEK_API_KEY,08model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',09configuration: {10baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',11},12})1314const response = await model.invoke([15{16role: 'system',17content: '你是一名面向前端开发者的助手,回答要清楚、简短。',18},19{20role: 'user',21content: '请用两句话确认 LangChain 与 DeepSeek 的连接已经正常。',22},23])2425console.log('invoke result:')26console.log(response.text)
执行命令:
1yarn first-call
这一段最值得记住的是 invoke() 的感觉:
- 把一份完整输入交给模型
- 等模型生成结束
- 一次性拿回结果
6. 第二次:换成流式输出
01import dotenv from 'dotenv'02import { ChatOpenAI } from '@langchain/openai'0304dotenv.config({ path: new URL('../.env.local', import.meta.url) })0506const model = new ChatOpenAI({07apiKey: process.env.DEEPSEEK_API_KEY,08model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',09configuration: {10baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',11},12})1314const stream = await model.stream([15{16role: 'system',17content: '你是一名面向前端开发者的助手,回答要自然、简短。',18},19{20role: 'user',21content: '请用一句话说明当前是流式输出验证。',22},23])2425process.stdout.write('stream result:\n')2627for await (const chunk of stream) {28process.stdout.write(chunk.text)29}3031process.stdout.write('\n')
执行命令:
1cd apps/aicompanion/playgrounds/langchain-first-call2yarn first-stream
这时候最大的变化只有一个:
不再等完整结果,而是边生成边输出。
所以你可以先把区别简单记成这样:
invoke():一次性拿结果stream():边生成边拿结果
7. 第三次:换成最小 Agent
前面两段代码都还是“直接调模型”。
现在再往前走一步,看看最小 Agent 是什么样。
01import dotenv from 'dotenv'02import { createAgent } from 'langchain'03import { ChatOpenAI } from '@langchain/openai'0405dotenv.config({ path: new URL('../.env.local', import.meta.url) })0607// 定义模型08const model = new ChatOpenAI({09apiKey: process.env.DEEPSEEK_API_KEY,10model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',11configuration: {12baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',13},14})1516// 定义 Agent17const agent = createAgent({18model,19tools: [],20systemPrompt: '你是一名面向前端开发者的助手,回答要自然、简短。',21})2223// 定义 message24const inputMessages = {25role: 'user',26content: '请用一句话说明当前是 Agent 流式调用验证。',27}2829// Agent 调用 stream 流式输出,返回的是消息流30const stream = await agent.stream({31messages: [inputMessages],32}, {33// 设置 streamMode 为 messages,返回的是消息流34streamMode: 'messages',35})3637process.stdout.write('agent stream result:\n')3839for await (const [messageChunk] of stream) {40if (messageChunk.content) {41process.stdout.write(messageChunk.text)42}43}4445process.stdout.write('\n')
执行命令:
1yarn first-agent
这段代码里最值得注意的地方有三个。
第一,模型配置本身没有变。
也就是说,Agent 不是另一套模型初始化方式,它还是建立在同一个模型对象之上。
第二,真正变化的是入口。
前面是:
1model.invoke(...)2model.stream(...)
这里变成了:
1agent.stream(...)
第三,createAgent() 让模型外面多了一层运行时包装。
现在这个例子里还没有工具,所以它看上去像是“绕了一层再调模型”。但后面一旦把 tools 接进去,这层包装的价值就会很明显。
8. 这个 Agent 版本里,多出来了什么
如果你第一次看 first-agent.ts,最容易疑惑的是:
“它和 first-stream.ts 看起来差不多,为什么还要单独写 Agent 版?”
原因就在于,后面我们整章要讲的主线不是“怎么调一个模型”,而是:
单个 Agent 怎样在一轮请求里调用多个工具,把事情做完。
所以这里先放一个最小 Agent,有两个作用:
- 先把
createAgent()和agent.stream()这些入口认熟 - 后面加工具时,不需要再突然切换思路
换句话说,first-call.ts 和 first-stream.ts 是在确认底层模型调用正常,first-agent.ts 则是在给后面的 Agent 主线铺路。
9. 总结
第一次不要试图把所有细节都吃透,先看懂下面四件事就够了。
9.1 dotenv.config({ path: new URL(...) })
这里显式指定了 .env.local 的位置。
这样做的好处是:只要脚本文件路径不变,就能稳定找到同一个环境变量文件,不容易因为执行目录变化而读错环境变量。
9.2 消息是数组,不是单个字符串
无论是 model.invoke()、model.stream(),还是 agent.stream(),这里传进去的都不是单一字符串,而是一组消息。
这会比“拼一整段字符串”更适合后面的多轮对话和工具调用场景。
9.3 response.text 和 chunk.text
在直接调模型时:
- 完整返回看
response.text - 流式返回看
chunk.text
第一次跑通时,先这么理解最省事。
9.4 streamMode: 'messages'
在 first-agent.ts 里,这一项很关键:
1{2streamMode: 'messages'3}
这样拿到的是消息流,终端里可以直接边生成边打印文字。
如果没有这层设置,Agent 的流式返回会更偏运行时事件结构,不适合做这篇的最小示例