← 博客书房

Agent 实战:从 Dify 到 LangGraph·LangChain.js 第一步:模型 IO 与流式

入门20 分钟

LangChain.js 第一步:模型 IO 与流式

实战锚点 · cooking-app:对照裸 OpenAI SDK 代码,理解 ChatModel、流式输出与结构化输出。

在碰任何框架之前,先看懂裸 API 长什么样——因为 LangChain 全部是对这一层的包装。

消息数组:模型看到的全部世界 system: "你是烹饪助手,回答要简洁实用" ← 人设/规则 user: "鸡胸肉怎么做嫩?" ← 用户问 assistant: "低温慢煮 60°C…" ← 模型上次答 user: "温度设多少?" ← 用户追问 模型没有记忆 · 每次调用把这整段数组重新发一遍 · 所以长对话越来越贵

从 30 行裸代码开始

下面是一个真实开源项目 easy-agent 的 Step 1——一个从零重造 Claude Code 的项目,这 30 行就是 LLM API 的全部本质:

import Anthropic from "@anthropic-ai/sdk";

export async function* streamMessage({ messages, system, tools }) {
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_AUTH_TOKEN });
  const stream = client.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 4096,
    messages,        // 消息数组:[{ role, content }]
    stream: true,
    ...(system ? { system } : {}),
    ...(tools?.length ? { tools } : {}),
  });

  for await (const event of stream) {
    // 每个事件 yield 给 UI 层渲染
    yield event;
  }
}

如果你之前用过 DeepSeek 或 OpenAI 的 API,你会发现格式几乎一样——因为整个行业都兼容 OpenAI 协议。换一个模型供应商,改的只是 URL 和 Key。

消息数组:唯一的输入格式

模型只认一种输入:消息数组。每条消息有一个角色和一段内容:

messages: [
  { role: "system",    content: "你是烹饪助手…" },     // 人设/规则(权重最高)
  { role: "user",      content: "鸡胸肉怎么做嫩?" },   // 用户问的
  { role: "assistant", content: "低温慢煮…" },          // 模型上次答的
  { role: "user",      content: "温度设多少?" },        // 用户追问的
]

四个知识点碎开来说:

角色谁在说话类比
system你写给模型的设定函数的隐式约定——不对用户展示,但影响所有输出
user用户函数的入参
assistant模型函数的返回值(也会成为下一轮的入参)
tool工具执行结果外部数据回填——后面工具调用章会讲

模型没有记忆。每次调用都是独立的——所谓「多轮对话」,只是把之前所有消息重新发一遍。这就是为什么长对话越来越贵(Dify 篇讲过的 token 账单)。

流式事件流:打字机效果的真相 message_start block_start text_delta × N block_stop message_stop "今" → "天" → "北" → "京" → … 每个字一个事件 UI 每收到一个 text_delta 就渲染一个字 · 不是前端在做动画 · 是模型本来就是一个 token 一个 token 长出来的

流式输出:不是一个字一个字「下载」

你看到的打字机效果,不是前端在做动画,是模型本来就是一个 token 一个 token 生成的(认知地图的 LLM 原理 3 分钟讲过这个)。

流式 API 返回的不是完整回答,而是一串事件流:

message_start       → 开始了,这是 message ID
content_block_start → 第一个内容块开始了(text 类型)
content_block_delta → 增量文字:"今"… "天"… "北"… "京"…
content_block_stop  → 这个块结束了
message_delta       → 快结束了,stop_reason 来了
message_stop        → 完整回答结束

easy-agent 的 Step 1 用一个 async generator 处理这些事件,每收到一个 text_delta 就 yield 给终端 UI 渲染——这就是所有流式 UI 的底层原理。

content block:回答不止是文字

模型的回答不是一个字符串,是一个内容块数组:

content: [
  { type: "text", text: "让我查一下天气…" },           // 文字块
  { type: "tool_use", id: "toolu_01", name: "getWeather", input: { city: "北京" } },  // 工具调用块
]

当模型想调用工具时,它不会说「请帮我调用 getWeather」,而是输出一个结构化的 tool_use 块。你的代码执行工具后,把结果作为 tool_result 塞回消息数组,再次调用模型。

这个循环就是 Agent 的核心——下一章会拆开讲。

那 LangChain 到底包装了什么?

现在你能自己回答这个问题了:

裸 API(你刚看的)LangChain 的包装
手动拼 messages 数组ChatPromptTemplate 帮你拼
手动处理 stream 事件.stream() 返回一个好用的 async iterator
手动解析 content blocks自动提取文字 / 工具调用
换模型供应商要改代码ChatOpenAI / ChatAnthropic 统一接口

LangChain 的价值不是「更强」,是「更省事」——但如果你不知道底下在发生什么,框架出问题时就抓瞎。

本章要点

  1. 模型 API = 发一个 POST,传消息数组,拿回复写
  2. 消息数组是唯一输入格式,四种角色各司其职
  3. 模型没有记忆,多轮对话 = 全量历史重发
  4. 流式 = 逐 token 事件流,不是前端动画
  5. content block 不止文字,还有 tool_use——这是 Agent 的起点

→ 下一章:工具调用——Agent 的「手」