第 3 课: TypeScript 高级特性在框架中的应用

0 阅读8分钟

课程目标

掌握 LangChain.js 中使用的 TypeScript 高级特性,理解这些特性如何支撑框架的类型安全和可组合性。


3.1 泛型 — 框架的类型骨架

3.1.1 Runnable 的泛型定义

LangChain.js 最核心的类型定义:

// libs/langchain-core/src/runnables/types.ts
export interface RunnableInterface<
  RunInput = any,    // 输入类型
  RunOutput = any,   // 输出类型
  CallOptions extends RunnableConfig = RunnableConfig,  // 调用选项
> extends SerializableInterface {

  invoke(input: RunInput, options?: Partial<CallOptions>): Promise<RunOutput>;

  batch(
    inputs: RunInput[],
    options?: Partial<CallOptions> | Partial<CallOptions>[],
    batchOptions?: RunnableBatchOptions
  ): Promise<(RunOutput | Error)[]>;

  stream(
    input: RunInput,
    options?: Partial<CallOptions>
  ): Promise<IterableReadableStreamInterface<RunOutput>>;

  transform(
    generator: AsyncGenerator<RunInput>,
    options: Partial<CallOptions>
  ): AsyncGenerator<RunOutput>;
}

三个泛型参数的作用

  • RunInput:这个 Runnable 接受什么类型的输入
  • RunOutput:这个 Runnable 产出什么类型的输出
  • CallOptions:调用时可以传哪些额外配置

3.1.2 泛型如何在 pipe 链中传递

当你写 prompt.pipe(model).pipe(parser) 时,TypeScript 在编译期做了什么:

// prompt 是 Runnable<{topic: string}, BaseMessage[]>
// model 是  Runnable<BaseMessage[], AIMessage>
// parser 是 Runnable<AIMessage, string>

const chain = prompt          // Runnable<{topic: string}, BaseMessage[]>
  .pipe(model)                // Runnable<{topic: string}, AIMessage>
  .pipe(parser);              // Runnable<{topic: string}, string>

pipe() 的签名确保了类型传递:

// 简化版 pipe 签名
class Runnable<RunInput, RunOutput> {
  pipe<NewOutput>(
    next: Runnable<RunOutput, NewOutput>  // 注意:next 的输入必须匹配 this 的输出
  ): Runnable<RunInput, NewOutput>        // 返回:输入保持不变,输出变为 next 的输出
}

如果类型不匹配,编译器直接报错

const parser = new StringOutputParser();  // Runnable<AIMessage, string>
const model = new ChatOpenAI();            // Runnable<BaseMessage[], AIMessage>

// ❌ 编译错误:string 不能赋值给 BaseMessage[]
const broken = parser.pipe(model);
//                        ~~~~~
// Type 'string' is not assignable to type 'BaseMessage[]'

3.1.3 泛型约束

CallOptions 使用了 extends 约束:

CallOptions extends RunnableConfig = RunnableConfig

这意味着:

  • CallOptions 必须是 RunnableConfig 的子类型(可以更具体,不能更宽泛)
  • 默认值是 RunnableConfig

实际例子:BaseChatModelCallOptions 扩展了 RunnableConfig,添加了 toolsstop 等模型特有选项。


3.2 RunnableConfig — 运行时配置的类型设计

// libs/langchain-core/src/runnables/types.ts
export interface RunnableConfig<
  ConfigurableFieldType extends Record<string, any> = Record<string, any>,
> extends BaseCallbackConfig {
  configurable?: ConfigurableFieldType;   // 可配置字段
  recursionLimit?: number;                // 递归深度限制(默认 25)
  maxConcurrency?: number;                // 最大并发数
  timeout?: number;                       // 超时(毫秒)
  signal?: AbortSignal;                   // 中止信号
}

设计要点

  • ConfigurableFieldType 泛型让不同 Runnable 可以定义自己的可配置字段
  • 继承 BaseCallbackConfig 获得 callbacks、tags、metadata 等通用配置
  • Partial<CallOptions> — 所有配置都是可选的

3.3 抽象类 vs 接口

3.3.1 为什么 Runnable 是抽象类而不是接口?

LangChain.js 选择了抽象类:

abstract class Runnable<RunInput, RunOutput> extends Serializable {
  // 可以提供默认实现
  async batch(inputs: RunInput[]): Promise<RunOutput[]> {
    return Promise.all(inputs.map((input) => this.invoke(input)));
  }

  // 可以提供组合方法
  pipe<NewOutput>(next: Runnable<RunOutput, NewOutput>): RunnableSequence {
    return new RunnableSequence({ first: this, last: next });
  }

  // 子类只需实现核心方法
  abstract invoke(input: RunInput): Promise<RunOutput>;
}

如果用接口:

// ❌ 接口无法提供默认实现
interface Runnable<RunInput, RunOutput> {
  invoke(input: RunInput): Promise<RunOutput>;
  batch(inputs: RunInput[]): Promise<RunOutput[]>;  // 每个实现者都得写
  pipe(next: Runnable): RunnableSequence;            // 每个实现者都得写
  stream(input: RunInput): AsyncGenerator<RunOutput>; // 每个实现者都得写
}

抽象类的优势

  • 提供 batchpipestream 等方法的默认实现,子类不用重复写
  • 可以继承 Serializable,获得序列化能力
  • 可以在基类中统一处理 callbacks、config 传播等横切关注点

trade-off

  • TypeScript 不支持多继承,选择继承 Runnable 就不能继承其他类
  • 实际上 LangChain.js 的所有组件都继承 Runnable,这个约束可以接受

3.4 AsyncGenerator 与流式输出

3.4.1 AsyncGenerator 基础

LangChain.js 的 stream() 方法返回异步可迭代对象,底层基于 AsyncGenerator

// AsyncGenerator 是一个可以异步逐个产出值的函数
async function* countToThree(): AsyncGenerator<number> {
  yield 1;
  await sleep(100);
  yield 2;
  await sleep(100);
  yield 3;
}

// 消费
for await (const num of countToThree()) {
  console.log(num);  // 1, 2, 3(每个间隔 100ms)
}

3.4.2 在 LangChain.js 中的应用

transform() 方法是流式管道的核心:

// RunnableInterface 中的 transform 签名
transform(
  generator: AsyncGenerator<RunInput>,   // 接收上游的流
  options: Partial<CallOptions>
): AsyncGenerator<RunOutput>;            // 产出下游的流

RunnableSequence 利用 transform 实现逐节点流式传播:

// 简化版实现
class RunnableSequence {
  async *_streamIterator(input) {
    // 第一个节点:把 input 变成流
    let currentStream = this.first._streamIterator(input);
    // 中间节点:每个节点 transform 上游的流
    for (const middle of this.middle) {
      currentStream = middle.transform(currentStream, config);
    }
    // 最后一个节点
    yield* this.last.transform(currentStream, config);
  }
}

3.4.3 IterableReadableStream

LangChain.js 封装了一个工具类,让 AsyncGenerator 和 Web ReadableStream 互通:

// 既是 AsyncIterable(可以 for await)
// 又是 ReadableStream(可以 pipe 到 HTTP Response)
class IterableReadableStream<T> extends ReadableStream<T> {
  async *[Symbol.asyncIterator](): AsyncGenerator<T> {
    const reader = this.getReader();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) return;
        yield value;
      }
    } finally {
      reader.releaseLock();
    }
  }
}

3.5 条件类型与映射类型

3.5.1 batch 方法的重载签名

batch() 方法根据 returnExceptions 参数返回不同类型:

// 不返回异常时,结果全是 RunOutput
batch(
  inputs: RunInput[],
  options?: Partial<CallOptions>,
  batchOptions?: { returnExceptions?: false }
): Promise<RunOutput[]>;

// 返回异常时,结果可能包含 Error
batch(
  inputs: RunInput[],
  options?: Partial<CallOptions>,
  batchOptions?: { returnExceptions: true }
): Promise<(RunOutput | Error)[]>;

这是 TypeScript 的函数重载,根据传入参数的字面量类型缩窄返回类型。

3.5.2 Tool 系统的类型推断

tool() 函数从 Zod schema 自动推断参数类型:

import { tool } from "@langchain/core/tools";
import { z } from "zod/v3";

const weatherTool = tool(
  async ({ city, unit }) => {
    // city 被推断为 string
    // unit 被推断为 "celsius" | "fahrenheit"
    return `${city}: 25°${unit === "celsius" ? "C" : "F"}`;
  },
  {
    name: "get_weather",
    description: "获取天气",
    schema: z.object({
      city: z.string().describe("城市名"),
      unit: z.enum(["celsius", "fahrenheit"]).default("celsius"),
    }),
  }
);

这背后的类型推断链:

  1. schema 的类型是 z.ZodObject<{ city: ZodString, unit: ZodEnum<...> }>
  2. z.infer<typeof schema> 推断出 { city: string, unit: "celsius" | "fahrenheit" }
  3. tool() 的泛型将 schema 推断结果传递给回调函数的参数类型

3.6 Zod v3 + v4 双版本兼容

3.6.1 为什么要支持双版本?

  • Zod v4 是新版本,有更好的性能和 API
  • 大量现有用户代码用的是 Zod v3
  • 作为框架,必须同时支持,让用户自己决定迁移时机

3.6.2 import 路径约定

import { z } from "zod/v3";        // Zod v3
import { z as z4 } from "zod/v4";  // Zod v4

3.6.3 互操作类型

框架内部定义了 InteropZodType 等类型来抹平差异:

// libs/langchain-core/src/utils/types/zod.ts (简化)

// 可以是 v3 或 v4 的 ZodType
type InteropZodType = ZodTypeV3 | ZodTypeV4;

// 统一的 parse 函数
async function interopParseAsync(schema: InteropZodType, data: unknown) {
  if (isZodV3(schema)) {
    return schema.parseAsync(data);
  } else {
    return z4.parseAsync(schema, data);  // v4 用静态方法
  }
}

3.6.4 对开发者的意义

// 你可以用 v3 定义工具
const toolV3 = tool(fn, {
  schema: z.object({ query: z.string() }),  // Zod v3
});

// 也可以用 v4
const toolV4 = tool(fn, {
  schema: z4.object({ query: z4.string() }),  // Zod v4
});

// 框架内部统一处理

3.7 模板方法模式的类型体现

3.7.1 public 方法 vs protected 方法

abstract class BaseChatModel extends BaseLanguageModel {
  // public 方法:用户调用,包含通用逻辑(callbacks、config、重试等)
  async invoke(
    input: BaseMessageLike[],
    options?: Partial<CallOptions>
  ): Promise<AIMessage> {
    // 1. 处理 config、callbacks
    // 2. 调用 _generate()(子类实现)
    // 3. 触发 callbacks
    // 4. 返回结果
  }

  // protected 抽象方法:Provider 实现,只关注核心逻辑
  protected abstract _generate(
    messages: BaseMessage[],
    options: this["ParsedCallOptions"],
    runManager?: CallbackManagerForLLMRun
  ): Promise<ChatResult>;
}

类型设计的妙处

  • this["ParsedCallOptions"] — 使用 this 类型,让子类的选项类型自动传递
  • 子类只需实现 _generate(),所有通用能力(callbacks、retry、stream)由基类提供

3.8 关键类型工具集

3.8.1 常见类型速查

类型定义位置用途
RunnableInterface<I, O>runnables/types.ts所有 Runnable 的接口
RunnableConfigrunnables/types.ts运行时配置
BaseMessagemessages/base.ts消息基类
AIMessagemessages/ai.tsAI 回复消息
AIMessageChunkmessages/ai.ts流式 AI 消息分片
InteropZodTypeutils/types/zod.tsZod v3/v4 兼容类型
StructuredToolInterfacetools/types.ts工具接口

3.8.2 类型推断链示例

完整追踪一条链的类型传递:

// Step 1: ChatPromptTemplate
const prompt = ChatPromptTemplate.fromMessages([
  ["system", "You are a {role}"],
  ["human", "{question}"],
]);
// 类型: Runnable<{ role: string, question: string }, ChatPromptValue>

// Step 2: ChatOpenAI
const model = new ChatOpenAI();
// 类型: BaseChatModel = Runnable<BaseMessageLike[], AIMessage>
// ChatPromptValue 可以转为 BaseMessage[],所以 pipe 成功

// Step 3: StringOutputParser
const parser = new StringOutputParser();
// 类型: Runnable<AIMessage | AIMessageChunk, string>

// Step 4: 组合
const chain = prompt.pipe(model).pipe(parser);
// 类型: Runnable<{ role: string, question: string }, string>

// Step 5: 调用
const result: string = await chain.invoke({
  role: "expert",      // ✅ 类型检查通过
  question: "hello",   // ✅ 类型检查通过
  // typo: "hell",     // ❌ 编译错误:Object literal may only specify known properties
});

3.9 实用技巧

3.9.1 利用类型系统调试

pipe() 报错时,检查前一个 Runnable 的输出类型和后一个的输入类型是否匹配。

3.9.2 从测试看类型用法

# 查看类型测试文件
ls libs/langchain-core/src/runnables/tests/
# 其中 *.test-d.ts 是类型测试

类型测试用 expectTypeOf

import { expectTypeOf } from "vitest";

expectTypeOf(chain.invoke).parameter(0).toMatchTypeOf<{ topic: string }>();
expectTypeOf(chain.invoke).returns.resolves.toBeString();

3.9.3 strict mode 的保护

tsconfig.json 开启了 strict: true,这意味着:

  • strictNullChecksnullundefined 不能随意赋值
  • noImplicitAny:不允许隐式 any
  • strictFunctionTypes:函数参数类型严格检查

这些检查在编译期阻止了大量运行时错误。


3.10 实战练习

练习 1:类型推断验证

在项目中创建一个测试文件,验证 pipe 链的类型推断:

import { ChatPromptTemplate } from "@langchain/core/prompts";
import { FakeChatModel } from "@langchain/core/utils/testing";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { expectTypeOf } from "vitest";

const prompt = ChatPromptTemplate.fromMessages([["human", "{topic}"]]);
const model = new FakeChatModel({});
const parser = new StringOutputParser();

const chain = prompt.pipe(model).pipe(parser);

// 验证链的输入输出类型
expectTypeOf(chain.invoke).parameter(0).toHaveProperty("topic");
expectTypeOf(chain.invoke).returns.resolves.toBeString();

练习 2:实现泛型 Runnable

尝试实现一个泛型转换 Runnable,体会泛型约束:

import { Runnable, RunnableConfig } from "@langchain/core/runnables";

class MapRunnable<T, U> extends Runnable<T[], U[]> {
  lc_namespace = ["custom"];
  private mapFn: (item: T) => U;

  constructor(mapFn: (item: T) => U) {
    super({});
    this.mapFn = mapFn;
  }

  async invoke(input: T[], _options?: Partial<RunnableConfig>): Promise<U[]> {
    return input.map(this.mapFn);
  }
}

const doubler = new MapRunnable<number, number>((n) => n * 2);
const result = await doubler.invoke([1, 2, 3]); // [2, 4, 6]

3.11 源码精读路线

优先级文件关注点
P0runnables/types.tsRunnableInterface 泛型定义、RunnableConfig 类型
P1tools/index.ts:1-70tool() 函数的泛型推断链
P1utils/types/zod.tsInteropZodTypeinteropParseAsync() Zod 双版本兼容
P2runnables/base.ts:72-100RunnableFuncRunnableLikeRunnableMapLike 类型别名

本课收获总结

级别你应该掌握的
🟢 基础理解泛型的基本概念;知道 AsyncGenerator 用于流式输出;理解 Runnable<I, O> 的含义
🔵 中阶掌握泛型在 pipe() 链中的传递规则;理解抽象类 vs 接口的 trade-off
🟡 高阶掌握条件类型、函数重载在框架 API 中的运用;理解 tool() 函数的类型推断链
🟠 资深理解 Zod v3/v4 互操作层的设计;分析 InteropZodType 的类型抹平策略
🔴 架构能评估类型系统如何在编译期阻止非法组合;理解"类型即文档"的框架设计理念

下一课预告

第 4 课开始深入 Runnable 体系的第一课——Runnable 接口与核心契约。我们将精读 runnables/base.ts 的核心 3542 行。