普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月12日首页

【前后端联调】接口代码生成 - hono + typescript + openapi 最佳实践

2026年2月11日 22:34

背景:在团队协作开发,前后端开发接口对齐永远是一道难题。在TS的世界里要保证类型安全往往浪费不必要的时间在定义类型上了。

实践方案:遵循 design-first,先设计接口,确定openapi文档。然后生成服务端API模板和前端请求SDK,保证类型安全,同时节省繁琐重复的代码编写时间。

约定 openAPI文档(通过apifox等方式)

这里展示使用apifox导出 openAPI描述文件。

image.png 一份简单的openAPI文档的json格式描述如下(默认模版.openapi.json):

{
  "openapi": "3.0.1",
  "info": {
    "title": "默认模块",
    "description": "",
    "version": "1.0.0"
  },
  "tags": [],
  "paths": {
"/sessions": {
      "post": {
        "summary": "登录",
        "deprecated": false,
        "description": "",
        "tags": [],
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {}
                }
              }
            }
          }
        },
        "security": []
      }
    },
  },
  "components": {
    "schemas": {},
    "responses": {},
    "securitySchemes": {}
  },
  "servers": [],
  "security": []
}  
  

paths下定义了你的路由(包括路径、方法、入参、响应等等)。
openAPI一般有两个导出形式json和yaml。这里简单起见,只放了1个登录接口(/sessions)的定义。

后端

后端框架,有很多选项,比如express/koa,hono,nest等等。我选择了hono,主要因为能支持bun/node多运行时和性能不错。

生成 hono 代码有两种比较推荐的方式:

下面主要介绍使用 hono-takibi 。如果是需要生成其他TS服务端框架的模板代码,可以选择使用Kubb 。如果是针对Java Springboot 则使用openapi-generator

hono-takibi生成模板

首先介绍下@hono/zod-openapi,这个是在hono框架的基础上,提供了http入参校验(基于zod)和文档生成(代码即文档)。

使用hono-takibi生成的代码是基于@hono/zod-openapi。基于json/yaml文件生成命令如下:

npx hono-takibi [path/openapi.json] -o [path/routes.ts]

比如我在项目根目录下,生成模板代码。

image.png

以登录注册为例,下面是生成的服务端代码routes.ts

import { createRoute, z } from '@hono/zod-openapi'
export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({ email: z.string(), password: z.string() })
            .openapi({ required: ['email', 'password'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})


export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: {
      content: {
        'application/json': {
          schema: z
            .object({
              email: z.string().openapi({ title: '邮箱' }),
              password: z.string().openapi({ title: '密码' }),
              emailCode: z.string().openapi({ title: '邮箱验证码' }),
              inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
            })
            .openapi({ required: ['email', 'password', 'emailCode'] }),
        },
      },
    },
  },
  responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
  security: [],
})

存在这么几个问题:

  1. 对于请求体的内容request.body.content 可以发现schema 是内联的,这不利于复用zod schema(不利于类型复用)。同理 还有request.queryresponses等。
  2. responses这里的响应体结构,没有做到复用。你可以看到{ 200: { content: {...}}}这种重复,这种不利于统一维护。

那么如何解决?

  1. 针对内联schema,我们这样解决:在apifox设计阶段,建立数据模型(这个是对应到服务端的DTO对象),最好是符合命名规范,对于query/body中的入参命名为XxxDto,对于响应结果命名为XxxResponseDto
  2. 针对响应体结构,定义统一的响应组件:在apifox设计阶段,建立响应组件(针对不同状态码200/201/400/401等)。同时针对固定响应结构需要设计一个数据模型ApiResponseDto 来填充。

数据模型和一些特定状态码的响应结构:

image.png

登录接口示例:

image.png

最终 再使用 hono-takibi 生成一下服务端 代码,如下:

import { createRoute, z } from '@hono/zod-openapi'

const UserResponseDtoSchema = z
  .object({ id: z.string(), email: z.string(), username: z.string(), avatar: z.string() })
  .openapi({ required: ['id', 'email', 'username', 'avatar'] })
  .openapi('UserResponseDto')

const CreateUserDtoSchema = z
  .object({
    email: z.string().openapi({ title: '邮箱' }),
    password: z.string().openapi({ title: '密码' }),
    emailCode: z.string().openapi({ title: '邮箱验证码' }),
    inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
  })
  .openapi({ required: ['email', 'password', 'emailCode'] })
  .openapi('CreateUserDto')

const LoginDtoSchema = z
  .object({ email: z.string(), password: z.string() })
  .openapi({ required: ['email', 'password'] })
  .openapi('LoginDto')

const ApiResponseDtoSchema = z
  .object({
    code: z.int().openapi({ description: '业务号码' }),
    data: z.object({}).nullable().openapi({ description: '业务数据' }),
    message: z.string().exactOptional().openapi({ description: '消息' }),
  })
  .openapi({ required: ['code', 'data'] })
  .openapi('ApiResponseDto')

const LoginResponseDtoSchema = z
  .object({
    accessToken: z.string().openapi({ description: '身份token' }),
    user: UserResponseDtoSchema,
  })
  .openapi({ required: ['accessToken', 'user'] })
  .openapi('LoginResponseDto')

const SuccessNullResponse = {
  description: '无内容的成功响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

const UnprocessableResponse = {
  description: '无法处理请求,失败响应',
  content: { 'application/json': { schema: ApiResponseDtoSchema } },
}

export const postSessionsRoute = createRoute({
  method: 'post',
  path: '/sessions',
  tags: [],
  summary: '登录',
  request: { body: { content: { 'application/json': { schema: LoginDtoSchema, examples: {} } } } },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: LoginResponseDtoSchema.nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})

export const postUsersRoute = createRoute({
  method: 'post',
  path: '/users',
  tags: [],
  summary: '注册',
  request: {
    body: { content: { 'application/json': { schema: CreateUserDtoSchema, examples: {} } } },
  },
  responses: {
    200: {
      description: '成功',
      headers: z.object({}),
      content: {
        'application/json': {
          schema: z
            .object({
              code: z.int().openapi({ description: '业务号码' }),
              data: z.object({}).nullable().openapi({ description: '业务数据' }),
              message: z.string().exactOptional().openapi({ description: '消息' }),
            })
            .openapi({ required: ['code', 'data'] }),
        },
      },
    },
    400: UnprocessableResponse,
  },
  security: [],
})


P.S.有个小问题,就是你定义 响应组件/响应时,一定要定义描述(description),否则生成代码会出现TS问题。

image.png

定义了description的效果:

image.png

集成到hono提供API服务

需要注意:前面提到的routes.ts中生成的xxxRoute是对参数校验和文档描述。实际上的路由逻辑是下面这样的:

import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import { postSessionsRoute, postUsersRoute } from './routes'

const app = new OpenAPIHono()

app.use(
  '/*',
  cors({
    origin: '*',
  })
)

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

app.openapi(postSessionsRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现登录逻辑
  return c.json({
    code: 0,
    data: {
      accessToken: 'token',
      user: {
        id: '1',
        email: 'user@example.com',
        username: 'user',
        avatar: '',
      },
    },
  })
})

app.openapi(postUsersRoute, async (c) => {
  const { password } = c.req.valid('json')
  if (password.length < 6) {
    return c.json(
      {
        code: 400,
        data: null,
        message: '密码长度小于6',
      },
      400
    )
  }
  // TODO: 实现注册逻辑
  return c.json({
    code: 0,
    data: null,
  })
})

export default app

使用 bun dev 运行(这是一个honojs项目),然后在apifox中测试如下:

image.png

前端

生成TS客户端代码,选择就很多了

  • 最轻量:openapi-typescript + openapi-fetch。见openapi-ts.dev
  • 框架党首选:Orval (配合 TanStack Query/React Query),见orval.dev
  • 体系一致性方案:Kubb,前后端都采用Kubb。见kubb.dev
  • @hey-api/openapi-ts FastAPI官方就推荐 这个。见heyapi.dev

这里没有推荐 openapi-generator 了,因为确实存在一些局限性,生成的前端SDK并不好用,在TS安全类型上不如其他选择(运行这个工具还要折腾Java环境)。

下面重点介绍下 openapi-typescript + openapi-fetchhey-api 2种方式。

openapi-typescript + openapi-fetch

使用

1.安装 两个依赖

pnpm add openapi-typescript -D
pnpm add openapi-fetch

2.运行openapi-typescript 生成ts类型

npx openapi-typescript "../默认模块.openapi.json" -o "app/utils/openapi/schema.d.ts"

3.编写客户端代码

import createClient from "openapi-fetch";
import type { paths } from "./schema";

export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });

// Export type for use in components
export type Client = typeof client;

4.一个登录例子:

const { 
data,  // only present if 2XX response
error  // only present if 4XX or 5XX response
} = await client.POST("/sessions", {
    body: {
        email,
        password,
    },
});

if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
} else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
}

一个好的 fetch 包装器绝对不应该使用泛型。 泛型需要更多的输入,而且可能会隐藏错误!

可以看出client 提供了GETPOST等方法,熟悉的写法,传入url和body参数。返回的结果包括data和error,data就是我们前面定义的ApiResponseDto,如下:

const data: {
    code: number;
    data: {
        accessToken: string;
        user: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
    };
    message?: string | undefined;
} | undefined

而 error,就是当状态码不是2xx时,不空,类型是UnprocessableResponse | XxxErrorResponse类型,如下:

const error: {
    code: number;
    data: Record<string, never> | null;
    message?: string;
} | undefined
特性

1.支持的请求库如下:

Library Size (min)
openapi-fetch 6 kB
openapi-typescript-fetch 3 kB
feature-fetch 15 kB
openapi-axios 32 kB

2.支持「中间件」。

使用axios的同学,肯定对请求拦截和响应拦截不陌生,而openapi-fetch 提供中间件完成同样的功能:

import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema";



const myMiddleware: Middleware = {
  async onRequest({ request, options }) {
    // set "Authorization" header,认证
    request.headers.set("Authorization", "Bearer " + "your_access_token"); 
    return request;
  },
  async onResponse({ request, response, options }) {
    const { body, ...resOptions } = response;
    console.log('body', body); // ReadableStream
    console.log('response', response);
    if (response.status === 401) {
      const error = new Error("Unauthorized");
      (error as Error & { status?: number }).status = 401;
      
      window.location.href = "/login";
      return
    }

    return response;
    // 或者 return new Response(body, { ...resOptions});
  },
  async onError({ error }) {
    // wrap errors thrown by fetch
    console.log('error', error);
    if (error instanceof Error) {
      return error;
    }
    return new Error("Oops, fetch failed", { cause: error });
  },
};


export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });


// register middleware
client.use(myMiddleware);

// Export type for use in components
export type Client = typeof client;

需要注意,openapi-fetch 一般不会抛出错误,比如401/403之类错误状态码(除非你在onResponse中手动抛出错误)。onError 回调函数允许你处理 fetch 抛出的错误。常见的错误包括 TypeError (当出现网络或 CORS 错误时可能发生)和 DOMException (当使用 AbortController 中止请求时可能发生)。

3.支持使用 DTO类型

之前生成的schema.d.ts中定义了interface components:

export interface components {
    schemas: {
        UserResponseDto: {
            id: string;
            email: string;
            username: string;
            avatar: string;
        };
        CreateUserDto: {
            /** 邮箱 */
            email: string;
            /** 密码 */
            password: string;
            /** 邮箱验证码 */
            emailCode: string;
            /** 邀请码 */
            inviteCode?: string;
        };
        LoginDto: {
            email: string;
            password: string;
        };
        ...

可以这样使用:

import { client , type components } from "~/utils/openapi";

const body: components["schemas"]["LoginDto"] = {
      email,
      password,
    }

4.对框架的支持

通过openapi-react-query库,也能支持结合tanstack query使用。use-query

hey-api

基础使用和特性

1.hey-api生成代码时,会创建一个文件夹(默认是"client")存放内容,和openapi-typescript相比,是它生成了一个默认的client,并且每个API都提供了方法直接调用(无需路径)

2.安装

pnpm add @hey-api/openapi-ts -D

3.配置openapi-ts命令

"scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "openapi-ts": "openapi-ts --input ../默认模块.openapi.json --output ./app/utils/heyapi"
  }

参数复杂了,也可以放配置文件/openapi-ts.config.ts中,比如:

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: 'http://127.0.0.1:8800/openapi.json', //支持远程url和本地openapi文件
  output: './app/APIs',
  plugins: [{
      name: '@hey-api/client-fetch',
      runtimeConfigPath: '@/hey-api',  // 控制client.gen.ts生成 
    },
  ], 
});

4.执行pnpm openapi-ts. 生成的sdk代码都在heyapi目录下,比较复杂。请求方法的代码都生成在sdk.gen.ts,而DTO类型都生成在types.gen.ts中。

app/
├── utils/
│ ├── heyapi/
│ │ ├── client/
│ │ ├── core/
│ │ ├── client.gen.ts
│ │ ├── index.ts
│ │ ├── sdk.gen.ts
│ │ └── types.gen.ts
│ └── index.ts

5.看看是如何使用的吧:

import { postSessions, type LoginDto } from "~/utils/heyapi";

import { client } from "~/utils/heyapi/client.gen";

// 需要先做一些基础的client配置 (也支持自己重新创建一个新 client)
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});


const body: LoginDto = {
      email,
      password,
    }

const { data, error } = await postSessions({
body: body,
  });

  if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
  } else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
  }

其中请求结果:data 和 error ,默认情况下 和 openapi-fetch的处理是一致的(都是不抛出错误,而是将错误通过error暴露)。

image.png

6.网络请求库方面也适配了fetch和axios,也支持tanstack query。并且计划未来对TS服务端框架支持,但还有很多没完成的(处在soon状态)。

结合 tanstack query

参考:plugin tanstack-query. 准备配置文件/openapi-ts.config.ts

import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  input: '../默认模块.openapi.json',
  output: './app/utils/heyapi',
  plugins: ['@tanstack/react-query'], 
});

执行 pnpm openapi-ts,此时生成的目录下多出一个文件 ./@tanstack/react-query.gen.ts

// This file is auto-generated by @hey-api/openapi-ts

import type { UseMutationOptions } from '@tanstack/react-query';

import { type Options, postSessions, postUsers } from '../sdk.gen';
import type { PostSessionsData, PostSessionsError, PostSessionsResponse, PostUsersData, PostUsersError, PostUsersResponse } from '../types.gen';

/**
 * 登录
 */
export const postSessionsMutation = (options?: Partial<Options<PostSessionsData>>): UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> => {
    const mutationOptions: UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postSessions({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

/**
 * 注册
 */
export const postUsersMutation = (options?: Partial<Options<PostUsersData>>): UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> => {
    const mutationOptions: UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> = {
        mutationFn: async (fnOptions) => {
            const { data } = await postUsers({
                ...options,
                ...fnOptions,
                throwOnError: true
            });
            return data;
        }
    };
    return mutationOptions;
};

整个登录页面的代码如下:

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {postSessionsMutation }  from "~/utils/heyapi/@tanstack/react-query.gen"
import { postSessions, type LoginDto } from "~/utils/heyapi";
import { client } from "~/utils/heyapi/client.gen";
import type { Route } from "./+types/login3";

// Configure the client
client.setConfig({
  baseUrl: "http://127.0.0.1:3000",
});

export function meta({}: Route.MetaArgs) {
  return [
    { title: "登录" },
    { name: "description", content: "用户登录" },
  ];
}

export default function Login() {
  const [email, setEmail] = useState("abc@example.com");
  const [password, setPassword] = useState("123456");

  const { mutate, isPending, isSuccess, error } = useMutation({
    // mutationFn: async (body: LoginDto) => {
    //   const { data, error } = await postSessions({
    //     body: body,
    //   });
    //   if (error) {
    //     throw error;
    //   }
    //   return data;
    // },
    ...postSessionsMutation(),
    onSuccess: (data) => {
      console.log("Login successful:", data);
      // 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
      // const token = data.data?.accessToken;
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutate({
      body: {
        email,
        password,
      }
    });
  };

  const status = isPending ? "loading" : isSuccess ? "success" : error ? "error" : "idle";
  const message = isSuccess
    ? "登录成功!"
    : error
    ? (error as any).message || "登录失败"
    : "";

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
      <div className="w-full max-w-md space-y-8 bg-white p-8 shadow rounded-lg">
        <div>
          <h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
            登录您的账户3
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div className="-space-y-px rounded-md shadow-sm">
            <div>
              <label htmlFor="email-address" className="sr-only">
                邮箱地址
              </label>
              <input
                id="email-address"
                name="email"
                type="email"
                autoComplete="email"
                required
                className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="邮箱地址"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                密码
              </label>
              <input
                id="password"
                name="password"
                type="password"
                autoComplete="current-password"
                required
                className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
                placeholder="密码"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
              />
            </div>
          </div>

          {message && (
            <div
              className={`text-sm text-center ${
                status === "success" ? "text-green-600" : "text-red-600"
              }`}
            >
              {message}
            </div>
          )}

          <div>
            <button
              type="submit"
              disabled={status === "loading"}
              className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
            >
              {status === "loading" ? "登录中..." : "登录"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

技术选型

技术方案比较

在选择 TypeScript OpenAPI 客户端生成方案时,核心的权衡点在于:“运行时开销 vs. 开发体验” 以及 “灵活性 vs. 自动化程度”

维度 openapi-typescript (+ fetch) Orval Kubb @hey-api/openapi-ts
定位 极简/轻量主义 前端框架深度集成 工业级流水线/全栈体系 官方首选/生产级SDK
生成内容 仅生成 TS 类型定义 类型 + API 请求函数 + Hooks + Mocks 类型 + Hooks + 验证器 + 路由 + 模版 类型 + SDK + Hooks + 验证器
核心优势 零运行时开销。直接利用 TS 类型收窄,包体积增加几乎为 0。 TanStack Query 亲儿子。一键生成全套 React/Vue Query 钩子。 高度可插件化。支持用 JSX 写代码生成模版,前后端契约高度一致。 FastAPI 官方推荐。由原作者维护的升级版,支持 20+ 插件。
状态管理集成 无 (需手动配合 TanStack Query) 内置支持 TanStack Query, SWR 内置支持 TanStack Query 插件支持 TanStack Query, Pinia
校验支持 无 (仅编译期) 支持 Zod 支持 Zod, Faker 支持 Zod, Valibot
网络库 原生 fetch (通过 openapi-fetch) Axios, Fetch, Hook Axios, Fetch Fetch, Axios, Angular, Nuxt
Mock 支持 内置支持 MSW 内置支持 Faker 计划支持 (Chance)
适用场景 极度关注包体积、喜欢原生 API、对封装有“洁癖”的项目。 典型的中后台管理系统,深度使用 React/Vue Query 的项目。 复杂项目,需要自定义生成逻辑(如自动生成后端路由、Schema)的团队。 需要高度成熟稳定、符合 FastAPI 体系或大厂规范的 SDK。
学习曲线 极低 中 (需配置 orval.config.js) 高 (需理解插件系统/模版)

  1. 如果你追求 “极致轻量”
  • 选择: openapi-typescript + openapi-fetch
  • 理由: 它是目前最符合 TypeScript 原生思维的方案。它不生成成千上万行的 JS 代码,只生成类型。你的 API 调用看起来就像原生的 fetch,但带有完美的自动补全。
  1. 如果你是 “TanStack Query (React/Vue Query) 用户”
  • 选择: Orval
  • 理由: Orval 是目前生成 React Query Hooks 最成熟的工具。它能自动生成 queryKey、处理缓存逻辑、甚至自动生成 MSW 的 Mock 数据,极大提升开发效率。
  1. 如果你想要 “全栈体系一致性”
  • 选择: Kubb
  • 理由: Kubb 的野心更大,它不仅是为了前端。通过它的插件系统,你可以把一套 OpenAPI 定义同时转化为前端的 Hooks 和后端的路由定义(如 Hono/Elysia),确保前后端代码在结构上是“镜像”的。
  1. 如果你追求 “官方规范与工程化”
  • 选择: @hey-api/openapi-ts
  • 理由: 这是 openapi-typescript-codegen 的正统继任者。如果你的后端是 FastAPI,或者你希望生成的代码像一个正式的 SDK(有完整的类、方法封装),它是最稳妥的选择。它的插件系统(Plugin)也让它在功能上非常全能。

总结一句话:

  • 想简单:openapi-typescript
  • 想省事(前端):Orval
  • 想折腾/全栈:Kubb
  • 想标准/大而全:Hey API

我个人的话,就主要从hey-apiopenapi-typescript/openapi-fetch中选了:

  • 对于管理端、ToB的应用,使用hey-api 或者 Orval
  • 对于比较轻量化的h5页面使用openapi-typescript/openapi-fetch
昨天以前首页

【TS版 2026 从零学Langchain 1.x】(三)Agent和Memory

2026年2月9日 20:03

一、智能体 Agent

我们前面已经在简单使用agent了,这部分我们详细聊聊agent。

和模型对话是一种 请求-响应的模式,这对于解决复杂问题显然不够,我们希望有一种机制能让模型自主解决问题,能记忆、调用工具、循环,于是Agent登场了。

一个最简单的agent创建如下。

import { createAgent } from "langchain";
const agent = createAgent({
  model,
  systemPrompt: SYSTEM_PROMPT,
  tools: [get_user_location, get_weather_for_location],
  responseFormat: toolStrategy(ResponseFormat),
  // debug: true, # 开启debug模式,会打印出agent的运行过程
  checkpointer=checkpointer,
  middleware=[]
});

可以看出重点的部分:

  • model: Agent的推理引擎
  • tools: Agent可以使用的工具(Tool calling)
  • responseFormat: Agent的输出格式(结构化输出)
  • checkpointer: Agent的状态检查点,用于保存和恢复Agent的状态(记忆)
  • middleware: 中间件

agent 本质上是一个有状态的有限状态机 (FSM)。它会自动在“模型推理”和“工具执行”之间跳转/循环,直到问题解决。 而checkpointer会记录每次invoke后的完整状态(可理解为“存档”)。

1. 中间件

先介绍下「中间件」的概念,因为后面很多能力都依赖「中间件」的机制(比如动态模型、短期记忆)。

对于学过web框架的同学一定对「中间件」有所了解,Langchian也是借助了一些成熟的工程设计思路,将「中间件」概念集成到Langchain,解决了以往存在的痛点:

  • 各种复杂配置
  • 侵入修改agent的逻辑

无中间件的Agent

image.png

有中间件的Agent

image.png

可以看出,Langchain中间件的本质就是钩子,在agent流程的每个步骤前后设置回调钩子,能对执行步骤进行拦截处理,从而达到更细粒度流程控制。

哪些常见的中间件呢?

  • 对话历史记录整理
  • 日志记录
  • 提示词转换
  • 工具选择
  • 重试、回退逻辑
  • 速率限制

具体怎么做呢?

使用 createMiddleware 函数创建中间件, Langchain提供了下面两种类型风格的钩子。

1.Node-style hooks:

  • beforeAgent - 代理启动前(每次invoke调用开始执行一次)
  • beforeModel - 每次模型调用前
  • afterModel - 每次模型响应后
  • afterAgent - 代理完成时(每个invoke调用结束执行一次)

例子:统计invoke调用次数,是否reach到顶。自定义中间件:

import { createMiddleware, AIMessage } from "langchain";

const createMessageLimitMiddleware = (maxMessages: number = 50) => {
  return createMiddleware({
    name: "MessageLimitMiddleware",
    beforeModel: {
      canJumpTo: ["end"],
      hook: (state) => {
        if (state.messages.length === maxMessages) {
          return {
            messages: [new AIMessage("Conversation limit reached.")],
            jumpTo: "end",
          };
        }
        return;
      }
    },
    afterModel: (state) => {
      const lastMessage = state.messages[state.messages.length - 1];
      console.log(`Model returned: ${lastMessage.content}`);
      return;
    },
  });
};

2.Wrap-style hooks:

  • wrapModelCall - 在每个模型调用周围
  • wrapToolCall - 在每个工具调用周围

例子:设置invoke失败重试的最大次数。自定义中间件如下:

import { createMiddleware } from "langchain";

const createRetryMiddleware = (maxRetries: number = 3) => {
  return createMiddleware({
    name: "RetryMiddleware",
    wrapModelCall: (request, handler) => {
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
          return handler(request);
        } catch (e) {
          if (attempt === maxRetries - 1) {
            throw e;
          }
          console.log(`Retry ${attempt + 1}/${maxRetries} after error: ${e}`);
        }
      }
      throw new Error("Unreachable");
    },
  });
};

下面我们会逐渐了解和学习到这些装饰器的使用。

2. 模型

模型的配置分为静态模型动态模型

  • 静态模型:在agent创建时就确定好的模型,比如OpenAI的gpt-3.5-turbogpt-4等。
  • 动态模型:在agent运行时根据上下文动态切换的模型,比如根据用户输入的问题选择不同的模型。

我们重点看下动态模型是如何实现的,这里给出一个场景:根据用户问题的复杂度,动态选择模型。

  • 使用qwen_32b模型判断问题复杂度
  • 简单,则使用deepseek V3.2;复杂,则使用GLM 4.7
  • 最终模型返回回答

动态模型的选择需要依靠「中间件」的方式来实现。

1.定义三个模型


function createChatModel(model: string, maxTokens: number) {
  return new ChatOpenAI({
    model,
    apiKey: settings.siliconflow_api_key,
    configuration: {
      baseURL: settings.siliconflow_base_url,
    },
    temperature: 0.9,
    maxTokens,
    timeout: 60_000,
  });
}

const glmModel = createChatModel(settings.glm_model, 10_000);
const dsModel = createChatModel(settings.ds_model, 10_000);
const qwenRouterModel = createChatModel(settings.qwen3_32b_model, 64);

2.工具函数,根据“模型响应”用来判断问题复杂度


/**
 * 从输入中提取最新的用户文本
 * @param input 输入内容,可能是字符串或包含消息数组的对象
 * @returns 最新的用户文本内容
 */
function extractLatestUserText(input: unknown): string {
  if (typeof input === "string") return input;

  const messages: BaseMessage[] | undefined = Array.isArray(input)
    ? (input as BaseMessage[])
    : (input as { messages?: BaseMessage[] } | null | undefined)?.messages;

  if (!messages?.length) return "";
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    const msg = messages[i];
    if (msg instanceof HumanMessage) return String(msg.content);
  }
  return String(messages.at(-1)?.content ?? "");
}


/**
 * 判断用户问题的复杂度
 * @param userText 用户输入的文本
 * @returns 问题复杂度,"simple" 或 "complex"
 */
async function judgeComplexity(userText: string): Promise<"simple" | "complex"> {
  const response = await qwenRouterModel.invoke([
    {
      role: "system" as const,
      content:
        "你是问题复杂度分类器。根据用户问题判断复杂度:\n- simple:单一事实/常识问答、简单翻译/润色、很短的直接回答、无需多步推理或设计。\n- complex:需要多步推理、方案设计/架构、长文写作、复杂代码/调试、严谨数学推导、对比权衡。\n只输出:simple 或 complex。",
    },
    { role: "user" as const, content: userText },
  ]);

  const text = String((response as any)?.content ?? "")
    .trim()
    .toLowerCase();

  if (text === "simple" || text.includes("simple") || text.includes("简单")) return "simple";
  if (text === "complex" || text.includes("complex") || text.includes("复杂")) return "complex";
  return "complex";
}

3.定义中间件,拦截模型请求

const dynamicModelMiddleware = createMiddleware({
  name: "DynamicModelMiddleware",
  wrapModelCall: async (request, handler) => {
    const userText = extractLatestUserText({ messages: (request as any)?.messages });
    const complexity = await judgeComplexity(userText);
    const model = complexity === "simple" ? dsModel : glmModel;
    request.model = model
    return handler(request);
  },
});

4.测试


/** 测试动态模型选择 */
async function testDynamicModelSelection() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: dsModel as any,
    checkpointer,
    contextSchema,
    middleware: [dynamicModelMiddleware],
  });

  const config = { configurable: { thread_id: "1" }, context: { userId: "1" } };

  const r1 = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和1.11 哪个数字大?" }] },
    config,
  );
  const ai1 = r1.messages.at(-1);
  console.log("响应内容:\n", ai1?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai1));
  /*
  调用模型:
  deepseek-ai/DeepSeek-V3.2-Exp
  */

  const r2 = await agent.invoke(
    {
      messages: [
        {
          role: "user",
          content: "请用langchain 1.x 设计一个简单的问答系统,用户可以向系统咨询某地的天气信息,包括天气工具调用。",
        },
      ],
    },
    config,
  );
  const ai2 = r2.messages.at(-1);
  console.log("响应内容:\n", ai2?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai2));

  /*
  调用模型:
  Pro/zai-org/GLM-4.7
  */
}

3. 工具

构建Agent的时候可以传入一个tools数组,绑定工具。

Agent的工具部分,包括三种场景:

  • 工具的错误处理
  • ReAct循环中使用
  • 动态工具

1.先说说工具的错误处理,有时候工具处理出错了,我们希望反馈给LLM 自定义的错误信息。

1.1 继续前面的例子中 “1.9 和1.11 哪个数字大?”的问题,有时候LLM回答不正确,那么我希望它能调用工具来回答。首先工具定义如下:

import { createAgent, createMiddleware, tool, toolStrategy } from "langchain";
const compareTwoNumbers = tool(
  ({ a, b }: { a: number; b: number }) => {
    if (a > b) return 1;
    if (a < b) return -1;
    return 0;
  },
  {
    name: "compare_two_numbers",
    description: "比较两个数字a,b的大小",
    schema: z.object({
      a: z.number().describe("第一个数字"),
      b: z.number().describe("第二个数字"),
    }),
  },
);

1.2 可能出现调用tool出错(比如传参错误,内部触发边界错误等等),那么,可以使用 wrapToolCall 定义tool调用阶段的钩子(中间件),来处理错误。

const handleToolErrorsMiddleware = createMiddleware({
  name: "HandleToolErrorsMiddleware",
  wrapToolCall: async (request, handler) => {
    try {
      return await handler(request);
    } catch (e) {
      // 返回自定义的错误消息给LLM
      return new ToolMessage({
        content: `Tool error: Please check your input and try again. (${e})`,
        tool_call_id: request.toolCall.id || '',
      });
    }
  },
});

1.3 测试agent


/** 2.测试工具:比较两个数字 */
async function testToolCompareTwoNumbers() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: dsModel,
    checkpointer,
    tools: [compareTwoNumbers],
    contextSchema,
    middleware: [ handleToolErrorsMiddleware],
  });

  const r = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和 1.11 哪个数字大?" }] },
    { configurable: { thread_id: "1" }, context: { userId: "1" } },
  );
  const ai = r.messages.at(-1);
  console.log("响应内容:\n", ai?.content);
  console.log("调用模型:\n", getModelNameFromMessage(ai));


  /*
  响应内容:
  比较结果是 **1**,这意味着 1.9 > 1.11。

  所以,**1.9** 比 **1.11** 大。

  虽然1.11在小数点后有两位数字,但比较小数大小时是看整体数值:
  - 1.9 实际上是 1.90
  - 1.11 是 1.11
  - 1.90 > 1.11
  调用模型:
  deepseek-ai/DeepSeek-V3.2-Exp
  */
}

2.ReAct循环中使用。工具是可以在agent循环中被反复使用的。

2.1 上面的testToolCompareTwoNumbers就是一个例子:调用一次Tool compareTwoNumbers后发现 可以得出答案,就停止循环了返回结果。如果发现问题还没解决就会继续 思考/调用工具 循环,直到有最终答案。

3.动态工具。我们可以预先注册工具,然后根据上下文动态调用工具。

3.1 比如 我 希望不同用户角色,能调用的工具是不一样的。普通用户无法使用管理员才能调用的工具。

3.2 官方示例(拦截工具调用,替换tools):

import { createAgent, createMiddleware } from "langchain";

const toolSelectorMiddleware = createMiddleware({
  name: "ToolSelector",
  wrapModelCall: (request, handler) => {
    // Select a small, relevant subset of tools based on state/context
    const relevantTools = selectRelevantTools(request.state, request.runtime);
    const modifiedRequest = { ...request, tools: relevantTools };
    return handler(modifiedRequest);
  },
});

const agent = createAgent({
  model: "gpt-4.1",
  tools: allTools,
  middleware: [toolSelectorMiddleware],
});

4. 响应格式

1.createAgent提供了一个参数responseFormat,可以传入一个zod对象,用来约束输出,这块内容在 第二篇有介绍过。底层是LLM模型的结构化输出强约束和Langchain的校验兜底。

2.如果没有 responseFormat,需要从agent.invoke 的结果的messages中取最后一条消息,得到最终的回答,并且是字符串.

3.而使用 responseFormat后,agent.invoke 的结果会多一个structuredResponse字段, 并且是一个结构化对象

4.我们继续之前的比较数字大小的例子,这次对响应结果加约束。

const compareResultSchema = z.object({
  num1: z.number().describe("第一个数字"),
  num2: z.number().describe("第二个数字"),
  result: z.number().int().describe("比较结果,1 表示 num1 大于 num2,-1 表示 num1 小于 num2,0 表示相等"),
});


/** 测试响应格式 */
async function testResponseFormat() {
  const checkpointer = new MemorySaver();

  const agent = createAgent({
    model: glmModel,
    checkpointer,
    tools: [compareTwoNumbers],
    contextSchema,
    responseFormat: compareResultSchema,
    middleware: [handleToolErrorsMiddleware],
  });

  const r = await agent.invoke(
    { messages: [{ role: "user", content: "1.9 和 1.11 哪个数字大?" }] },
    { configurable: { thread_id: "1" }, context: { userId: "1" } },
  );

  console.log(r.structuredResponse);
  /*
  {
    num1: 1.9,
    num2: 1.11,
    result: 1,
  }
  */
  console.log(r.messages.at(-1)?.content);
  /*
    Returning structured response: {"num1":1.9,"num2":1.11,"result":1}
  */
}

5. 状态检查点

checkpointer 是用来保存和恢复 agent 的状态。它具备以下能力

  • 记忆能力(记忆历史消息)
  • 线程隔离
  • 故障恢复和“时空旅行”

记录历史对话记录

如果你不传递checkpointer,那么agent是没有记忆能力的,下面例子中模型将无法记住你的名字

async function testNoCheckpointer() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 1: createAgent 不带 checkpointer (应该无记忆)");
  console.log("=".repeat(50));

  const agent = createAgent({ model });
  const config = { configurable: { thread_id: "1" } };

  console.log("\n【步骤 1】\n [用户]: 嗨!我叫 Bob。");
  const response1 = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 Bob。" }] },
    config,
  );
  console.log(`[Agent]: ${response1.messages.at(-1)?.content ?? ""}`);

  console.log("\n【步骤 2】\n [用户]: 我叫什么名字?");
  const response2 = await agent.invoke(
    { messages: [{ role: "user", content: "我叫什么名字?" }] },
    config,
  );
  console.log(`[Agent]: ${response2.messages.at(-1)?.content ?? ""}`);
}

下面例子中模型能记住你的名字。

async function testWithCheckpointer() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 2: createAgent 带 checkpointer (应该有记忆)");
  console.log("=".repeat(50));

  const checkpointer = new MemorySaver();
  const agent = createAgent({ model, checkpointer });
  const config = { configurable: { thread_id: "thread-1" } };

  console.log("\n【步骤 1】\n [用户]: 嗨!我叫 Alice。");
  const response1 = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 Alice。" }] },
    config,
  );
  console.log(`[Agent]: ${response1.messages.at(-1)?.content ?? ""}`);

  console.log("\n【步骤 2】\n [用户]: 我叫什么名字?");
  const response2 = await agent.invoke(
    { messages: [{ role: "user", content: "我叫什么名字?" }] },
    config,
  );
  console.log(`[Agent]: ${response2.messages.at(-1)?.content ?? ""}`);
}

线程隔离

所谓线程隔离就是你可以用同一个agent 发起多个会话,每个会话是独立的,互不干扰的。 下面的例子演示了,什么是线程隔离。通过第二个参数 { configurable: { thread_id: "thread-A" } } 设置线程。

def test_checkpointer_thread_isolation():
    """
    测试 3: create_agent 使用 checkpointer 和不同的 thread_id。
    预期:记忆通过 thread_id 隔离。
    """
    print("\n" + "="*50)
    print("测试 3: create_agent 线程隔离")
    print("="*50)
    
    memory = InMemorySaver()
    agent = create_agent(qwen3_32b_model, checkpointer=memory)
    
    # 1. 线程 A 交互
    print("\n[线程 A] 用户: 嗨!我叫 Charlie。")
    agent.invoke(
        {"messages": [{"role": "user", "content": "嗨!我叫 Charlie。"}]},
        {"configurable": {"thread_id": "thread-A"}}
    )

    # 2. 线程 B 交互 
    print("\n[线程 B] 用户: 你好!我叫 疯狂踩坑人")
    agent.invoke(
        {"messages": [{"role": "user", "content": "你好!我叫 疯狂踩坑人"}]},
        {"configurable": {"thread_id": "thread-B"}}
    )
    
    
    # 3. 线程 A 交互 (问名字)
    print("\n[线程 A] 用户: 我叫什么名字?")
    response_a = agent.invoke(
        {"messages": [{"role": "user", "content": "我叫什么名字?"}]},
        {"configurable": {"thread_id": "thread-A"}}
    )
    print(f"[线程 A] Agent: {response_a['messages'][-1].content}")


    # 4. 线程 B 交互 (问名字)
    print("\n[线程 B] 用户: 我叫什么名字?")
    response_b = agent.invoke(
        {"messages": [{"role": "user", "content": "我叫什么名字?"}]},
        {"configurable": {"thread_id": "thread-B"}}
    )
    print(f"[线程 B] Agent: {response_b['messages'][-1].content}")
    

image.png

检查点 checkpoint

运行下面代码,查看memory的变化


async function testCheckpoints() {
  console.log("\n" + "=".repeat(50));
  console.log("测试 4: 检查 checkpointer 保存的 checkpoint");
  console.log("=".repeat(50));

  const checkpointer = new MemorySaver();
  const agent = createAgent({ model, checkpointer });
  const config = { configurable: { thread_id: "thread-1" } };

  console.log("\n[用户]: 嗨!我叫 疯狂踩坑人。");
  const response = await agent.invoke(
    { messages: [{ role: "user", content: "嗨!我叫 疯狂踩坑人。" }] },
    config,
  );
  console.log(`[Agent]: ${response.messages.at(-1)?.content ?? ""}`);

  const checkpoints: unknown[] = [];
  for await (const checkpoint of checkpointer.list({ configurable: { thread_id: "thread-1" } })) {
    checkpoints.push(checkpoint);
  }
  console.log("checkpoint 数量:", checkpoints.length);
  for (const c of checkpoints) {
    console.log(c, "\n");
  }
}

memory保存了很多检查点,每个检查点都有idts(时间)和channel_values.messages等信息。

image.png

每个checkpoint代表一次存档,通过某一个checkpoint就可以恢复agent的状态,这样你可以穿越回去到之前invoke的任一个状态,这非常重要。

二、记忆 Memory

短期记忆 Short-term memory

短期记忆主要指在单个会话内,LLM 能够“记得”刚刚说过的话。它的目的是保持对话的连贯性。

管理消息 - SummarizationMiddleware

1.前面提到的checkpointer 会保存你的历史消息,但是这样会存在两个问题:

  • LLM 上下文限制:发送给大模型的 Token 数量超过其最大窗口,导致报错。
  • 成本与延迟增加:每次调用都会携带冗长的历史记录,消耗更多 Token 且模型响应变慢

2.针对这些问题,业界的处理方案基本就是:

  • 裁剪最早的消息,保持一个固定大小的"窗口"
  • 对裁剪的历史消息,使用LLM进行总结,作为一条

image.png

3.下面用一个例子来演示,Langchain是如何通过SummarizationMiddleware管理对话消息的。这个中间件做的事就是上面的方案:裁剪+总结。

import { createAgent, summarizationMiddleware } from "langchain";

const systemPrompt = "你是一个人工智能助手";

function buildModels() {
  const baseConfig = {
    apiKey: settings.siliconflow_api_key,
    configuration: {
      baseURL: settings.siliconflow_base_url,
    },
    timeout: 60_000,
  } as const;

  const agentModel = new ChatOpenAI({
    ...baseConfig,
    model: settings.qwen3_32b_model,
    temperature: 0.7,
    maxTokens: 2000,
  });

  const summarizerModel = new ChatOpenAI({
    ...baseConfig,
    model: settings.qwen3_32b_model,
    temperature: 0.2,
    maxTokens: 512,
  });

  return { agentModel, summarizerModel };
}


async function testSummarizationShortMemory() {
  const { agentModel, summarizerModel } = buildModels();
  const checkpointer = new MemorySaver();
  const agent = createAgent({
    model: agentModel,
    tools: [],
    systemPrompt,
    middleware: [
      summarizationMiddleware({
        model: summarizerModel,
        trigger: { messages: 4 }, // 也可以按 tokens 来
        keep: { messages: 4 }, // 也可以按 tokens 来
        summaryPrefix: "对话摘要:",
        summaryPrompt:
          "请将以下对话历史压缩成简短的中文摘要,保留关键信息(事实、偏好、约束、决定、结论):\n{messages}",
      }),
    ],
    checkpointer,
  });

  const userInputs = [
    "我叫小明,住在北京。",
    "请记住我更喜欢用中文回答。",
    "我这周想做一个 LangChain 的学习计划。简短控制在100字",
    "计划要按天拆分,每天不超过1小时。简短控制在100字",
    "顺便提醒我:周三晚上要健身。最后请把所有安排再用要点总结一次。",
  ];

  const config = { configurable: { thread_id: "short-memory-demo" } };

  for (let idx = 0; idx < userInputs.length; idx += 1) {
    const text = userInputs[idx] || '';
    const r = await agent.invoke({ messages: [{ role: "user", content: text }] }, config);
    const messages = r.messages;
    const last = messages.at(-1);

    console.log(
      `\n[Turn ${idx + 1}] 当前上下文消息数:${messages.length}(trigger=4, keep=4)\n`,
    );
    console.log(`用户:${text}`);
    console.log(`助手:${String(last?.content ?? "")}`);
  }
}       

4.trigger: { messages: 4 },keep: { messages: 4 } 表示当消息超过4条(不包含system_prompt)时触发裁剪,裁剪的信息保留4条。 这样一来,「消息窗口」大小控制在4。

5.理论上keep可以大于trigger,这样总结消息和keep的消息在内容上就会有重叠。

6.trigger和keep 除了通过message控制,还可以通过tokens控制,比如超过多少tokens就压缩。

7.从第三轮开始,当前消息数:6. 这意味下一次对话前,就会触发压缩,实际消息窗口大小为4.

image.png

8.下面是第三轮对话的messages打印结果,可以看到前面总结的消息。可以发现Langchain默认的做法是: 使用了总结性提示'here is a summary of ...',将1,2,3,4次消息发给了LLM做总结'

image.png

9.此外,Langchain还提供了RemoveMessage 来删除指定的消息,这个结合Langgraph会比较实用。

存储介质

import { MemorySaver } from "@langchain/langgraph"; 中的MemorySaver是将状态数据保存在内存中,那么程序已结束,这些状态就会丢失。所以官方更推荐在生产环境使用 PostgresSaver

bun add @langchain/langgraph-checkpoint-postgres

在生产环境中,使用一个由数据库支持的检查点:

import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";

const DB_URI = "postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable";
const checkpointer = PostgresSaver.fromConnString(DB_URI);

当然,除了Postgres数据库介质,还有其他存储介质,见文档,比如Sqlite。

bun add @langchain/langgraph-checkpoint-sqlite

但是这有个问题,@langchain/langgraph-checkpoint-sqlite 包依赖了Node的better-sqlite3,这个包目前不支持bun. 所以,只能用替代品:

bun add @beshkenadze/langgraph-checkpoint-libsql
import { SqliteSaver } from "@beshkenadze/langgraph-checkpoint-libsql";

async function testSqliteSaver() {
  const { agentModel } = buildModels();

  const checkpointer = SqliteSaver.fromConnString("file:checkpoints.db");
  const agent = createAgent({
    model: agentModel,
    tools: [],
    systemPrompt,
    checkpointer,
  });

  const config = { configurable: { thread_id: "test_sqlite_saver" } };

  const r1 = await agent.invoke(
    { messages: [{ role: "user", content: "你好,我叫“疯狂踩坑人”" }] },
    config,
  );
  console.log("[assistant]", String(r1.messages.at(-1)?.content ?? ""));
  // [assistant] 
  // 我是你的AI助手!不过“疯狂踩坑人”这个名字挺有...

  const r2 = await agent.invoke(
    { messages: [{ role: "user", content: "请问我叫什么名字?" }] },
    config,
  );
  console.log("[assistant]", String(r2.messages.at(-1)?.content ?? ""));
  // [assistant] 
  // 你叫“疯狂踩坑人”呀...
}

你会发现运行目录下多出一个checkpoints.db的数据库文件。 用sqlite客户端工具(比如vscode插件Database Client)打开查看checkpoints表,可以看到,这些数据都保存到了表里。

image.png

长期记忆 Long-term memory

长期记忆是指跨越多天、多周甚至数个不同会话,系统依然能记得用户的偏好、事实或历史背景。

  • 核心机制: 检索增强生成(RAG)与外部数据库。它不直接塞进当前的 Prompt 窗口,而是通过“按需检索”的方式工作。
  • LangChain 实现方式:
    • 向量数据库(Vector Stores): 如 Pinecone, Milvus, Chroma。将历史对话或知识切片并嵌入(Embedding),当用户提问时,通过语义搜索找回相关片段。
    • VectorStoreRetrieverMemory: LangChain 特有的组件,允许将向量数据库作为记忆组件挂载。
    • 实体记忆(Entity Memory): 提取对话中的特定实体(如“我的名字叫 疯狂踩坑人”)并存入结构化数据库。
  • 存储位置: 外部持久化数据库(磁盘)。
  • 优势: 理论上拥有无限容量,且不会占用不必要的 Token。只有当相关信息被触发时,才会被提取出来。

这个我们后面再聊,后面会慢慢介绍向量数据库和RAG。

❌
❌