普通视图

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

手把手从 0 诠释大模型 API 的本质: Tools + MCP + Skills

作者 ArcX
2026年2月15日 14:05

本文写于 2026 年 02 月 15 日.

如今 AI Agent 的各种新概念层出不穷:

  • Tools
  • MCP
  • Skills

许多人都会有这样的疑问: Tools 和 MCP 有什么区别? 我用了 MCP 还需要 Tools 吗? Skills 是取代 MCP 的吗? 本文会从 LLM API 的底层设计开始, 一步步介绍 Tools 和 MCP 的区别, 手动实现一个非常简易的 MCP (简易到你会觉得"就这?"), 最后简单提一下 Skills.

几个重要事实

  • 大模型是无状态的, 它对你们的过往对话一点都没有记忆. 每次调用 LLM API, 都是一次全新的请求, 就像换了一个完全陌生的人说话.
  • 大模型本身的开发(或许)很难, 需要很强的数学知识. 但是大模型应用开发不难, 做纯工程开发的传统程序员也可以很快上手.
  • MCP 和 Skills 都是纯工程层面的设施, 和 AI 毫无关系. 也就是说, 在这两个概念出现以前, 你完全可以自己实现一套类似的机制, 不需要 LLM API 支持.

基于以上几个事实, 本文会选择 Anthropic API 来解释. 因为 OpenAI 的 Responses API 提供了一个叫做 previous_response_id 的参数, 很容易误导人以为 LLM 本身有记忆功能. 但实际上 LLM 是没有记忆的, 这个 previous_response_id 并不会给 LLM 使用, 而是 OpenAI 的服务层面的工程设施, 相当于 OpenAI 帮我们存了历史记录, 然后发给 LLM. Conversations API 同理.

相比之下, Anthropic API 就原生了许多, 更容易感受到 LLM API 的本质.

技术栈

请注意区分 @anthropic-ai/sdk@anthropic-ai/claude-agent-sdk. 前者是 Anthropic API 的封装, 本质上是一个 HTTP Client, 封装了大量的调用 API 的方法; 后者是对 Claude Code (Claude CLI) 的封装, 封装了大量调用 claude 命令行的方法.

本文会使用 GLM-4.7-flash 这个兼容 Anthropic API 的免费模型来节约成本, 毕竟 LLM 应用开发最大的痛点就是每次调试运行都需要花钱.

const client = new Anthropic({
  baseURL: 'https://api.z.ai/api/anthropic', // 国际版, 你也可以使用国内版, 国内版认证方式是 apiKey
  authToken: ZAI_API_KEY,
});

Hello World

首先从一个最简单的请求开始:

const resp = await client.messages.create({
  max_tokens: 1024,
  messages: [
    {
      role: 'user',
      content: '英国的首都是哪里',
    },
  ],
  model: 'glm-4.7-flash',
});

console.log(resp);

Output (省略掉不重要的字段):

{
  "id": "msg_202602151117137d34660397a4418d",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "英国的首都是**伦敦**(London)。"
    }
  ],
  "stop_reason": "end_turn"
}

多轮对话

正如上面反复提到的, LLM 是无状态的, 每次调用都像是一个全新的完全陌生的人对话. 想象一下, 如果你要和一个人聊天, 每聊完一句, 对面都会换一个人, 那么对方换的人应该如何继续和你的聊天? 当然就是把你之前的聊天历史全部看一遍. 所以调用 LLM 的时候, 每次都需要把历史记录全部传过去.

// 用一个 messages 数组来维护历史记录
const messages: MessageParam[] = [
  {
    role: 'user',
    content: '英国的首都是哪里',
  },
];

const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
});

// 重点: 将 LLM 的第一次回复放到数组里
messages.push({
  role: 'assistant',
  content: resp.content,
});

// 再加入第二次对话内容
messages.push({
  role: 'user',
  content: '介绍一下这个城市的污染情况',
});

console.log(inspect(messages));

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
});

console.log(resp2);

可以看看第二次调用 API 传入的 messages 内容是:

[
  {
    "role": "user",
    "content": "英国的首都是哪里"
  },
  {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "英国的首都是**伦敦**。"
      }
    ]
  },
  {
    "role": "user",
    "content": "介绍一下这个城市的污染情况"
  }
]

而 resp2 成功返回了伦敦的污染情况, 说明 LLM 确实感知到了上一次对话内容的城市是伦敦.

{
  "id": "msg_20260215115536fd125b1bca954cf6",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "伦敦作为全球国际化大都市和前工业革命中心,其污染历史可以追溯到维多利亚时代,且至今仍是全球空气质量治理的“典型样本”..." // 我手动省略, 减少篇幅, 并非 LLM 省略
    }
  ],
  "stop_reason": "end_turn"
}

所以你应该也知道了, 所谓的 context windows, 其实可以简单理解为 messages 数组的文本长度, 而不是单条消息的长度.

Tools

原始方法

LLM 就像一个很聪明(虽然有时候会很蠢, 但是我们先假定 LLM 很聪明)的大脑, 但是它只有大脑, 没有眼睛 - 意味着它无法接收外界的信息(除了手动传入的 messages), 比如读一个文件; 没有手 - 意味着它无法做出任何行为, 比如修改一个文件. (可以把 LLM 想象成一个遮住眼睛的霍金).

Tools 就相当于给一个大脑安装了外置眼睛和手. 我们先用最朴素的方式让 LLM 调用工具: 直接在 prompt 里写, 有哪些工具, params 分别是什么, 然后让 LLM 选择一个使用, 并提供 params.

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `写一句话介绍中国农历马年.
      你有以下 tools 可以调用:
      1. { name: "write", description: "write content to a file", params: 
        { "content": {"type": "string", description: "content"} },
        { "path": {"type": "string", description: "the path of the file to write"} },
       }

      2. { name: "read", description: "read content of a file", params: 
        { "path": {"type": "string", description: "the path of the file to read"} }
       }

       请你选择一个工具使用, 并且提供正确的 params. 你需要输出一个 JSON
    `,
  },
];

Output:

{
  "id": "msg_202602151218464370b8983c6c474d",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "```json\n{\n  \"tool\": \"write\",\n  \"params\": {\n    \"content\": \"中国农历马年象征着奔腾不息的活力与豪迈,寓意着奋进、自由与驰骋。\",\n    \"path\": \"/马年介绍.txt\"\n  }\n}\n```"
    }
  ],
  "stop_reason": "end_turn"
}

可以看到, LLM 做到了选择正确的工具, 提供的参数内容倒是没问题, 但是存在以下几个巨大的问题:

  1. 返回的 text 本质上是个字符串. 虽然在 prompt 里明确要求了需要返回一个 JSON, 但是 LLM 依然返回了一个 JSON markdown, 而不是纯 JSON 字符串.
  2. prompt 并不可靠. LLM 无法做到 100% 遵循 prompt, 尤其是能力比较差的模型, 它可能会输出"好的, 下面是我调用工具的 JSON: xxx". 也就是说, 并不能保证输出一定是一个 JSON markdown.
  3. 就算输出是一个 JSON markdown, 我们还需要去解析这个 markdown, 一旦涉及到嵌套, 也就是 params 里也包含反引号, 会更加复杂.
  4. 无法保证输出的 JSON 100% 遵循了 prompt 里的格式, 比如我在调用的时候就出现过返回了 arguments 字段, 而不是 params.

基于以上问题, Tool Use (或者叫 Tool Call, Function Call, 一个意思. Anthropic 的官方术语是 Tool Use) 被内置进了 LLM, 成为了 LLM 自身的一个能力. 也就是说, 如果一个 LLM 不支持 Tool Use, 那么我们基本是没法在工程层面去做 polyfill, 也就无法实现调用 tool.

标准方法

上面的例子, 换标准的 Tool Use 方法:

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `写一个关于中国农历马年的一句话介绍, 写入 test.txt 里`,
  },
];

const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools: [
    {
      name: 'write',
      description: 'write content to a file',
      input_schema: {
        type: 'object',
        properties: {
          content: {
            type: 'string',
            description: 'content',
          },
          path: {
            type: 'string',
            description: 'the path of the file to write',
          },
        },
      },
    },
    // read 同理, 省略掉
  ],
});

Output:

{
  "id": "msg_20260215123307fffbbd1b9fd84652",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "我来写一句关于中国农历马年的介绍并保存到文件中。"
    },
    {
      "type": "tool_use",
      "id": "call_49f0c1dbe920406192ce9347",
      "name": "write",
      "input": {
        "content": "中国农历马年象征着活力、热情与自由,是充满朝气与拼搏精神的吉祥年份。",
        "path": "test.txt"
      }
    }
  ],
  "stop_reason": "tool_use"
}

可以看到这次的 content 里多了一个 tool_use 的 block, 里面写明了需要调用的 tool 的名字和参数. 这个 block 的类型是结构化的, 也就是说可以 100% 保证格式是正确, 符合预期的 (但是不能保证 100% 有这个 block, 取决于 LLM 的能力, 太蠢的 LLM 可能无法决策到底用哪个 tool). 这样我们就可以根据这个结构化的 tool_use block, 去执行对于的函数调用.

结果回传

考虑一个场景: 让 LLM 阅读一个文件并分析内容. 经过上面的内容, 你应该知道具体的流程是:

  1. User 要求 LLM 阅读某个文件并分析内容, 并且传入 read tool schema
  2. LLM 决定使用 read tool, 参数是文件路径
  3. User 根据路径读取文件内容, 然后传给 LLM
  4. LLM 成功输出分析结果
const tools: ToolUnion[] = [
  // 本文省略具体内容, read 和 write 两个 tools
];

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `分析一下 package.json`,
  },
];

// 初始请求
const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});

// 把 LLM 的第一次返回加入到 messages 里
messages.push({
  role: 'assistant',
  content: resp.content,
});

// 第一次返回大概率会包含 tool_use block
// content 是一个数组, 可能额外包含一个 text, 也可能直接就是一个 tool_use
// content 可能包含多个 tool_use, 用户需要把所有的都调用, 然后根据 tool_use_id 去匹配结果
const toolUseResults: ContentBlockParam[] = [];
for (const block of resp.content) {
  if (block.type === 'tool_use') {
    switch (block.name) {
      case 'read':
        try {
          const content = await readFile(block.input.path, 'utf-8');
          toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content, is_error: false }); // is_error 告诉 LLM 这个调用是否成功
        } catch (err) {
          toolUseResults.push({
            tool_use_id: block.id,
            type: 'tool_result',
            content: JSON.stringify(err),
            is_error: true,
          });
        }

        break;

      case 'write':
        try {
          await writeFile(block.input.path, block.input.content);

          toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content: 'success', is_error: false });
        } catch (err) {
          toolUseResults.push({
            tool_use_id: block.id,
            type: 'tool_result',
            content: JSON.stringify(err),
            is_error: true,
          });
        }
        break;
    }
  }
}
// 将 tool use results 传给 LLM
messages.push({ role: 'user', content: toolUseResults });

console.log(inspect(messages));

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});
console.log(resp2);

第二次传给 LLM 的 messages 为:

[
  {
    "role": "user",
    "content": "分析一下 package.json"
  },
  {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "我来帮你分析 package.json 文件。首先,我需要读取这个文件来查看它的内容。"
      },
      {
        "type": "tool_use",
        "id": "call_eeb390dfe8f345269ff95f42",
        "name": "read",
        "input": {
          "path": "package.json"
        }
      }
    ]
  },
  {
    "role": "user",
    "content": [
      {
        "tool_use_id": "call_eeb390dfe8f345269ff95f42",
        "type": "tool_result",
        "content": "{\n  \"name\": \"gh-scout\",\n  \"module\": \"index.tsx\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"@types/bun\": \"latest\"\n  },\n  \"peerDependencies\": {\n    \"typescript\": \"^5\"\n  },\n  \"dependencies\": {\n    \"@anthropic-ai/sdk\": \"^0.74.0\"\n  }\n}\n",
        "is_error": false
      }
    ]
  }
]

第二次从 LLM 返回的结果为:

{
  "id": "msg_2026021512452122fcf4da65474784",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "## 📦 `package.json` 文件分析\n\n### 基本信息\n\n| 字段 | 值 | 说明 |\n|------|-----|------|\n| **name** | `\"gh-scout\"` | 项目名称,用于 npm 包标识 |\n| **module** | `\"index.tsx\"` | ES 模块入口文件 |\n| **type** | `\"module\"` | 使用 ES6 模块语法 |\n| **private** | `true` | 标记为私有项目,防止误发布到 npm |\n\n### 依赖管理\n\n#### 🔧 开发依赖 (`devDependencies`)\n```json\n\"@types/bun\": \"latest\"\n```\n- 类型定义包,用于 Bun 运行时的 TypeScript 类型支持\n- `\"latest\"` 表示使用最新版本\n\n#### 🤝 对等依赖 (`peerDependencies`)\n```json\n\"typescript\": \"^5\"\n```\n- **关键点**:`typescript` 被标记为对等依赖\n- 这意味着**项目不直接依赖 TypeScript**,而是期望用户环境中已安装 TypeScript\n- 适用于插件/工具类项目,让用户自己选择 TS 版本\n\n#### ⚡ 运行时依赖 (`dependencies`)\n```json\n\"@anthropic-ai/sdk\": \"^0.74.0\"\n```\n- Anthropic AI 的官方 SDK\n- 版本范围 `^0.74.0` 表示安装 0.74.0 或更高版本(不超过主版本号)\n\n---\n\n### 📝 总结\n\n这是一个**轻量级的 TypeScript 工具项目**(`gh-scout`),主要特点:\n\n1. **无运行时依赖**:除了 Anthropic SDK 外,没有其他运行时依赖\n2. **依赖 TypeScript**:用户自己提供 TS 环境\n3. **使用 Bun**:通过 `@types/bun` 表明使用 Bun 运行环境\n4. **模块化设计**:ES 模块类型,入口为 `index.tsx`\n\n项目看起来是一个与 GitHub 相关的工具(从名字 \"gh-scout\" 推测),可能用于分析或监控 GitHub 相关的操作。"
    }
  ],
  "stop_reason": "end_turn"
}

可以看到, LLM 第一次告诉我们需要调用 read tool 来读取文件内容. 我们调用完毕后把结果传给 LLM, LLM 第二次就成功分析出了内容.

插个题外话: 看到这里, 你应该也觉得原生 LLM 的方式实在是太繁琐了.

  • messages 要手动维护
  • tool_use 要手动解析 LLM 的返回, 手动调用, 然后手动把结果传到 messages 数组里
  • 如果 LLM 后续还要调用其他 tools, 还需要手动写一个循环

这正是现在各种 AI Agent 框架的意义, 比如 LangChain, LangGraph, Agno 等, 它们底层其实也都是做这种事情, 和传统领域的框架一样, 把繁琐的步骤都封装好了, 就像写 React 就不需要手动去操作 DOM 一样.

MCP

上面的方式虽然繁琐, 但也完全覆盖了所有场景了. 任何 tool use 都可以用上面的方式去实现. 那么为什么还需要 MCP 呢?

MCP 是什么

MCP (model context protocol) 是一个协议, 定义了 MCP Client 和 MCP Server 的通信方式. MCP 的原理和 AI/LLM 没有任何关系, 只是定义了 tools/resources/prompt 三种信息的通信格式.

MCP 解决了什么问题

假设现在没有 MCP 这个概念.

众所周知, LLM 非常擅长写文档类的东西, 比如 PR description. 所以现在你想让 LLM 帮你在 github 提一个 PR. 你需要先定义一个 tool:

const tools: ToolUnion[] = [
  {
    name: 'github_create_pr',
    description: 'create a PR on github',
    input_schema: {
      type: 'object',
      properties: {
        repo: {
          type: 'string',
          description: 'The repo name. Format: {owner}/{repo_name}',
        },
        source_branch: {
          type: 'string',
          description: 'The source branch name',
        },
        target_branch: {
          type: 'string',
          description: 'The target branch name',
        },
        title: {
          type: 'string',
          description: 'The title of the PR',
        },
        description: {
          type: 'string',
          description: 'The description body of the PR',
        },
      },
    },
  },
];

然后实现这个 tool 的调用过程:

case 'github_create_pr':
  const { repo, source_branch, target_branch, title, description } = block.input;
  const [owner_name, repo_name] = repo.split('/');

  try {
    // 也可以用 gh cli
    const resp = await fetch(`https://api.github.com/repos/${owner_name}/${repo_name}/pulls`, {
      method: 'post',
      headers: {
        accept: 'application/vnd.github+json',
        authorization: 'Bearer GITHUB_TOKEN',
      },
      body: JSON.stringify({
        title,
        body: description,
        base: source_branch,
        head: target_branch,
      }),
    });

    toolUseResults.push({
      tool_use_id: block.id,
      type: 'tool_result',
      content: await resp.text(),
      is_error: false,
    });
  } catch (err) {
    toolUseResults.push({
      tool_use_id: block.id,
      type: 'tool_result',
      content: JSON.stringify(err),
      is_error: true,
    });
  }
  break;

每加一个这样的 tool, 都需要花费大量的精力. 但实际上这些 tools 是高度通用的, 调用 github 是一个很普遍的需求.

此时你可能想到, 那我封装一个 github_tools 不就可以了?

于是你行动力拉满, 自己(或者让 AI)封装了一个 github_tools, 发布到了 npm 上, 其他用户可以像这样使用你的库:

import { tools as githubTools, callTool } from '@arc/github_tools';

const tools = [...myTools, ...githubTools];

for (const block of resp.content) {
  if (block.type === 'tool_use') {
    if (block.name.startsWith('github')) {
      const result = await callTool(block);
    }
  }
}

但是此时又有了两个新的问题:

  1. 你的新项目使用了 Go/Rust, 用不了 npm 包.
  2. 由于 Anthropic API 太贵, 你决定迁移到 DeepSeek API, 但是 DeepSeek 对 Anthropic 的兼容性不是很好(假设), 有些格式不匹配, 导致你的库调用失败.

MCP 的出现就是为了解决上面的问题. MCP 本质上是把 tools 的定义和执行都外置出去了. MCP 分为 Client 和 Server, 其中 Server 就是外置出去的部分, 负责 tools 的定义和执行. 而 Client 就是留在 AI 应用的部分, 负责和 Server 通信:

  • Hi Server, 告诉我有哪些 tools 可以用?
  • Hi Server, 我现在要调用 github_create_pr 这个 tool, 参数是 { xxx }

最简易的 MCP 实现

知道了 MCP 的设计思想, 那么我们完全可以写一个最简易的实现:

const server = async ({ type, body }: { type: string; body?: any }): Promise<string> => {
  if (type === 'list_tools') {
    return JSON.stringify([
      {
        name: 'github_create_pr',
        description: 'create a PR on github',
        input_schema: {
          type: 'object',
          properties: {
            repo: {
              type: 'string',
              description: 'The repo name. Format: {owner}/{repo_name}',
            },
            source_branch: {
              type: 'string',
              description: 'The source branch name',
            },
            target_branch: {
              type: 'string',
              description: 'The target branch name',
            },
            title: {
              type: 'string',
              description: 'The title of the PR',
            },
            description: {
              type: 'string',
              description: 'The description body of the PR',
            },
          },
        },
      },
    ]);
  }

  if (type === 'call_tool') {
    switch (body.name) {
      case 'github_create_pr':
        const { repo, source_branch, target_branch, title, description } = body.input;
        const [owner_name, repo_name] = repo.split('/');
        try {
          const resp = await fetch(`https://api.github.com/repos/${owner_name}/${repo_name}/pulls`, {
            method: 'post',
            headers: {
              accept: 'application/vnd.github+json',
              authorization: 'Bearer GITHUB_TOKEN',
            },
            body: JSON.stringify({
              title,
              body: description,
              base: source_branch,
              head: target_branch,
            }),
          });
          return await resp.text();
        } catch (err) {
          return JSON.stringify(err);
        }
    }
  }

  return 'Unknown type';
};

为了简单起见, 我直接写的是一个函数. 你完全可以将其做成一个 HTTP server, 因为反正这个函数的返回类型是 string, 可以作为 HTTP Response.

然后再写一个 client:

class McpClient {
  async listTools() {
    const tools = await server({ type: 'list_tools' });
    return JSON.parse(tools) as ToolUnion[];
  }

  async callTool(name: string, params: any) {
    const res = await server({ type: 'call_tool', body: params });
    return res;
  }
}

发现了吗? 上面的代码和 LLM 一点关系都没有, 这也是我一直在强调的重点: MCP 是工程设计, 不是 LLM 自身能力. 你完全可以脱离 AI, 直接使用 github 的官方 mcp server, 手动调用里面提供的方法. AI 在这里面唯一做的事情只是帮你决定调用的 tool_name + params.

用我们自己实现的 MCP Client 和 Server 改写上面的代码:

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `分析一下 package.json`,
  },
];

const mcpClient = new McpClient();
const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools: await mcpClient.listTools(),
});

const toolUseResults: ContentBlockParam[] = [];
for (const block of resp.content) {
  if (block.type === 'tool_use') {
    if (block.name.startsWith('github')) {
      try {
        const result = await mcpClient.callTool(block.name, block.input);
        toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content: result, is_error: false });
      } catch (err) {
        toolUseResults.push({
          tool_use_id: block.id,
          type: 'tool_result',
          content: JSON.stringify(err),
          is_error: true,
        });
      }
    }
  }
}
messages.push({ role: 'user', content: toolUseResults });

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});
console.log(resp2);

瞬间简洁了不少. github 相关的 tools 定义和实现都外置到了 MCP Server 上, 这样就做了两层解耦:

  1. 具体语言解耦 - 你可以用任何语言实现 MCP Server, 只要它能处理字符串.
  2. LLM 解耦 - 你可以用任何支持 tool use 的 LLM, MCP 协议里单独定义了字段, 和 LLM 自己的字段无关.

Skills

现在你已经了解到了:

  1. Tool Use 是 LLM 自身的能力.
  2. MCP 不是 LLM 自身的能力, 而是工程设计, 辅助 Tool Use 用的.

那么最近很火的 Skills 又是什么呢? 是取代 MCP 的吗? 当然不是.

LLM 的 context 是非常宝贵的. 如果在系统提示词里放入太多的内容, 会导致系统提示词本身就占据大量 context. 举个例子, 假设你在开发一个 Coding Agent, 你集成了 github MCP Server, 那么每次 LLM API 调用, 都会把完整的 github MCP 相关的 tools 定义全部发给 LLM. 如果绝大部分用户根本就不会用 github 的能力, 那你就平白无故浪费了大量 context.

这就是 Skills 解决的问题: 渐进式披露, 或者叫按需加载.

我个人猜测 Skills 应该也是工程设计, 也不是 LLM 的能力, 因为我们完全可以自己实现一套机制, 用下面的系统提示词:

你是一个全能专家. 你拥有以下技能:

1. 做饭: 川菜, 粤菜, 日料, 英国美食.
2. 旅游: 规划旅游路线, 选择最佳景点, 解说历史遗迹.
3. 写代码: Typescript, Rust, Go, Python.
...
99. 视频制作: 制作爆款视频, 通过制造各种对立吸引流量.
100. Slides 制作: 制作精美的, 吸引领导眼光的 Slides.

所有的技能都被单独放到了 .skills 目录里. 当用户的问题与某个技能相关时, 你需要使用 Read tool 来读取对应技能的全部文档.

看到了吗? 系统提示词里只放了最基本的技能名字和简介(也就是 SKILL.md 开头的 name + description), 没有放具体技能的内容 (比如具体怎么做菜, 具体怎么写代码, 具体制造哪种对立更符合当下的热点), 大幅节约了 context.

如果此时用户问"帮我用 Rust 写个基本的 HTTP Server", 那么 LLM 第一条返回的消息应该就包含一个 read 的 tool_use, 读取 .skills/coding 里所有的内容, 里面就会包含具体的细节, 比如 "不要用 unwrap", "优先使用 axum 框架" 等. 用户把这些内容通过 tool_use_result 发给 LLM 后, LLM 再去写最终的代码给用户.

所以 Skills 也并不是什么神奇的事情, 并不是说 Skills 赋予了 AI 大量额外的能力, 只是单纯地通过按需加载, 节约了 context, 从而可以放大量的 Skills 在目录里. 毕竟在 Skills 出现之前, 你完全也可以把具体的写代码能力写到系统提示词里, LLM 照样会拥有完整的写代码的能力.

总结

本文从 0 开始一步步讲述了 LLM API 的设计, 多轮对话, 原生 Tool Use 的方式, MCP 的原理, Skills 的思想. 让我们回顾一下几个核心要点:

Tool Use - LLM 的核心能力

Tool Use 是 LLM 模型本身的能力, 需要模型在训练时就支持. 它让 LLM 能够:

  • 理解工具的定义和参数
  • 根据用户意图决策应该调用哪个工具
  • 结构化的格式输出工具调用信息

如果一个 LLM 不支持 Tool Use, 我们几乎无法通过工程手段来弥补, 因为用 prompt 的方式既不可靠, 又难以解析.

MCP - 工程层面的协议

MCP 是纯粹的工程设计, 和 AI 完全无关. 它解决的是工程问题:

  • 跨语言: 用任何语言都可以实现 MCP Server, 不局限于某个生态
  • 解耦: tools 的定义和实现从应用代码中分离出去
  • 复用: 同一个 MCP Server 可以被多个应用、多个 LLM 使用
  • 标准化: 统一了工具的通信协议, 避免了各自为政

MCP 的价值在于降低了集成成本, 让开发者可以专注于业务逻辑, 而不是重复造轮子.

Skills - 优化 Context 的策略

Skills 同样是工程层面的优化, 核心思想是:

  • 按需加载: 不把所有能力都塞进系统提示词
  • 渐进式披露: 需要什么能力才加载什么内容
  • 节约 Context: 让有限的 context window 发挥更大价值

Skills 不是新技术, 而是一种最佳实践模式, 在 Skills 概念出现之前我们就可以自己实现类似机制.

三者的关系

Tool Use, MCP, Skills 并不是互相取代的关系, 而是相辅相成:

┌─────────────────────────────────────────┐
│          AI Application                 │
│  ┌────────────────────────────────┐     │
│  │  Skills (按需加载能力)          │     │
│  │  - 系统提示词优化                │     │
│  │  - Context 管理                 │     │
│  └────────────────────────────────┘     │
│                                         │
│  ┌────────────────────────────────┐     │
│  │  MCP Client (工具集成层)        │     │
│  │  - 从 MCP Server 获取工具定义    │     │
│  │  - 调用 MCP Server 执行工具     │     │
│  └────────────────────────────────┘     │
│                    ↓                    │
│  ┌────────────────────────────────┐     │
│  │  LLM with Tool Use (AI 能力层) │     │
│  │  - 理解工具                      │     │
│  │  - 决策调用                      │     │
│  └────────────────────────────────┘     │
└─────────────────────────────────────────┘
                    ↕
        ┌──────────────────────┐
        │   MCP Server (外部)   │
        │   - github tools      │
        │   - filesystem tools  │
        │   - database tools    │
        └──────────────────────┘
  • Tool Use 是基础, 没有它其他都无从谈起
  • MCP 让工具的集成变得简单和标准化
  • Skills 让能力的组织变得高效

实践建议

在实际开发 AI 应用时:

  1. 选择支持 Tool Use 的 LLM: 这是硬性要求, 没有商量余地
  2. 优先使用现有的 MCP Server: 不要重复造轮子, github/filesystem 等常用工具都有官方 MCP Server
  3. 合理组织 Skills: 如果你的系统提示词超过几千 tokens, 考虑用 Skills 模式进行按需加载
  4. 理解工程本质: MCP 和 Skills 都是工程问题, 理解其原理后完全可以根据需求自己实现或调整

最后

希望本文帮助你厘清了 Tool Use, MCP, Skills 三者的关系. 记住核心观点: Tool Use 是 AI 能力, MCP 和 Skills 是工程设计. 它们各司其职, 共同构建了现代 AI Agent 的能力体系.

当你在开发 AI 应用时遇到问题, 先问自己: 这是 LLM 能力的问题, 还是工程设计的问题? 如果是 LLM 能力的问题, 我们就没法自己解决了, 只能换 LLM; 如果是工程设计的问题, 在这个极高速发展的行业, 如果还没有解决方案, 那我们是完全有能力去解决的.

目前属于 LLM 能力(需要训练支持)的概念:

  • Tool Use
  • Thinking
  • Structured Output
  • Multimodal

属于工程设计, 但是很难去 polyfill, 需要服务提供方支持的概念:

  • Streaming
  • Cache
  • Batch API

属于工程设计, 并且比较容易 polyfill 的概念:

  • MCP
  • Skills
  • SubAgent
昨天以前首页

Claude Code 作者再次分享 Anthropic 内部团队使用技巧

作者 Immerse
2026年2月14日 14:52

大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。

关注公众号:沉浸式AI,获取最新文章(更多内容只在公众号更新)

个人网站:yaolifeng.com 也同步更新。

转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!


Boris 又发了一份 Anthropic 内部的 Claude Code 使用心得。

看完觉得挺实用,记录几条:

1. 多开 worktree 同时跑 3-5 个 git worktree,每个开一个独立会话。团队里公认这个最提效。Boris 自己习惯用 git checkout,但大部分人更爱 worktree。

2. 复杂任务先规划 遇到复杂活儿就开 plan mode。可以让一个 Claude 写计划,另一个当幕僚审查。跑偏了就切回去重新规划。验证环节也会专门进计划模式。

3. 错误后更新 CLAUDE.md 每次纠错完都加一句:"更新你的 CLAUDE.md,别再犯同样的错。"反复迭代到错误率明显降下来。

4. 自建 Skills 库 把常用操作做成 Skills 提交到 git,各项目复用。一天做两次以上的事就该做成 Skills。

5. 让 Claude 自己修 bug 接入 Slack MCP,把 bug 讨论帖扔给 Claude,说一句"修它"就行。或者直接"去修失败的 CI",不用管细节。

6. 提高提示词质量 试试"严格审查这些改动,测试不过不准建 PR",让 Claude 当审查员。或者"证明给我看这能跑通",让它对比 main 和功能分支的差异。

7. 追求更优方案 碰到平庸的修复就说:"基于现在掌握的信息,废掉这个方案,实现更优雅的。"任务前写详细规格,减少歧义。描述越具体,输出越好。

8. 终端配置 团队在用 Ghostty 终端,支持同步渲染、24 位色彩和完整 Unicode。用 /statusline 自定义状态栏显示上下文用量和 git 分支。给标签页做颜色编码和命名,一个任务一个标签页。

9. 语音输入 说话比打字快三倍,提示词也会详细很多。macOS 连按两次 fn 就能开启。

10. 用子代理 想让 Claude 多花点算力就加"use subagents"。把任务分给子代理,主代理的上下文窗口保持干净。

详情:x.com/bcherny/status/2017742741636321619 x

i18n Ally Next:重新定义 VS Code 国际化开发体验

作者 Lyda
2026年2月14日 12:38

生成关于 i18n Ally Next 的图片.png

做国际化(i18n)是前端开发中最「看起来简单、做起来要命」的事情之一。翻译文件散落各处,键名拼写错误要到运行时才发现,新增语言要手动逐个补全,源语言改了但翻译没跟上……如果你也深有同感,这篇文章就是写给你的。

为什么要做 i18n Ally Next?

i18n Ally 是 VS Code 生态中最受欢迎的国际化插件之一,由 @antfu 创建。它提供了内联注解、翻译管理、文案提取等一系列强大功能,极大地提升了 i18n 开发体验。

但随着时间推移,原项目的维护逐渐放缓。社区积累了大量 Issue 和 PR 未被处理,一些现代框架(如 Next-intl、Svelte 5)的支持也迟迟没有跟上。更关键的是——原项目没有文档站点,所有使用说明散落在 README 和 Wiki 中,新用户上手成本很高。

i18n Ally Next 正是在这样的背景下诞生的——我们 fork 了原项目,在保留所有经典功能的基础上,持续修复 Bug、新增特性、改善性能,并且从零搭建了完整的文档体系

为什么文档这么重要?

原版 i18n Ally 功能强大,但有一个致命问题:你不知道它能做什么

很多开发者安装后只用到了内联注解这一个功能,对审阅系统、自定义框架、正则匹配、路径匹配等高级功能一无所知。这不是用户的问题,是文档缺失的问题。

i18n Ally Next 的文档站点从以下几个维度重新组织了内容:

🧱 结构化的指南体系

  • 快速开始 — 从安装到看到第一个内联注解,5 分钟搞定
  • 支持的框架 — 完整列出 25+ 支持的框架及其自动检测机制
  • 语言文件格式 — JSON、YAML、JSON5、PO、Properties、FTL……每种格式的用法和注意事项
  • 命名空间 — 大型项目必备的翻译文件组织方式
  • 文案提取 — 从硬编码字符串到 i18n 键的完整工作流
  • 审阅系统 — 团队协作翻译的质量保障
  • 机器翻译 — 8 种翻译引擎的配置与对比

🏗️ 框架最佳实践

不同框架的 i18n 方案差异很大。我们为每个主流框架编写了专属的最佳实践指南:

📋 完整的配置参考

每一个配置项都有:类型、默认值、使用场景说明和代码示例。不再需要猜测某个配置是干什么的。

新增功能详解

🤖 Editor LLM:零配置 AI 翻译

这是 i18n Ally Next 最具创新性的功能之一。它直接调用你编辑器内置的大语言模型进行翻译——无需 API Key,无需额外配置

{ "i18n-ally-next.translate.engines": ["editor-llm"] }

它会自动检测你的编辑器环境:

  • VS Code — 调用 GitHub Copilot
  • Cursor — 调用 Cursor 内置模型
  • Windsurf — 调用 Windsurf 内置模型

更强大的是,Editor LLM 支持批量翻译。当你选择翻译多个键时,它会将同一语言对的翻译请求合并为一次 API 调用,按 JSON 格式批量处理,大幅提升翻译速度并降低 token 消耗。

你也可以指定模型:

{ "i18n-ally-next.translate.editor-llm.model": "gpt-4o" }

🦙 Ollama:完全离线的本地翻译

对于有数据安全要求的团队,Ollama 引擎让你可以使用本地部署的大模型进行翻译,数据完全不出本机

{
  "i18n-ally-next.translate.engines": ["ollama"],
  "i18n-ally-next.translate.ollama.apiRoot": "http://localhost:11434",
  "i18n-ally-next.translate.ollama.model": "qwen2.5:latest"
}

通过 OpenAI 兼容 API 调用,支持任何 Ollama 上可用的模型。翻译 prompt 经过专门优化,能正确保留 {0}{name}{{variable}} 等占位符。

🔌 8 种翻译引擎全覆盖

引擎 特点 适用场景
Google 免费、语言覆盖广 日常开发
Google CN 国内可直接访问 国内开发者
DeepL 翻译质量最佳 面向用户的正式翻译
OpenAI 灵活、可自定义 API 地址 需要高质量 + 自定义
Ollama 完全离线、数据安全 企业内网环境
Editor LLM 零配置、批量翻译 快速迭代
百度翻译 国内 API、中文优化 中文项目
LibreTranslate 开源自托管 完全自主可控

引擎可以配置多个作为 fallback:

{ "i18n-ally-next.translate.engines": ["editor-llm", "deepl", "google"] }

🕵️ 陈旧翻译检测

这是一个容易被忽视但极其重要的功能。当源语言的文案发生变更时,其他语言的翻译可能已经过时了——但你不会收到任何提醒。

i18n Ally Next 的陈旧翻译检测(Stale Translation Check)解决了这个问题:

  1. 插件会记录每个键的源语言快照
  2. 当你运行检测命令时,它会对比快照与当前值
  3. 发现变更后,你可以选择:
    • 全部重新翻译 — 一键将所有过期翻译发送到翻译引擎
    • 逐个确认 — 逐条查看变更内容,决定是否重新翻译
    • 仅更新快照 — 确认当前翻译仍然有效,更新基准

这意味着你的翻译永远不会「悄悄过期」。

🔍 全项目扫描与批量提取

单文件的硬编码检测很有用,但真正的 i18n 迁移需要全项目级别的能力。

「扫描并提取全部」命令可以:

  1. 扫描项目中所有支持的文件(可通过 glob 配置范围)
  2. 检测每个文件中的硬编码字符串
  3. 显示扫描结果摘要(N 个文件,M 个硬编码字符串)
  4. 确认后自动批量提取,为每个字符串生成键名并写入 locale 文件
{
  "i18n-ally-next.extract.scanningInclude": ["src/**/*.{ts,tsx,vue}"],
  "i18n-ally-next.extract.scanningIgnore": ["src/generated/**"],
  "i18n-ally-next.extract.keygenStrategy": "slug",
  "i18n-ally-next.extract.keygenStyle": "camelCase"
}

📝 审阅系统(v2的功能)

翻译不是一个人的事。i18n Ally Next 内置了审阅系统(Review System),支持:

  • 逐条审阅翻译结果,标记为「通过」或「需修改」
  • 留下评论,与团队成员异步协作
  • 审阅数据以 JSON 存储在项目中,可纳入版本控制
  • 翻译结果可先保存为候选翻译translate.saveAsCandidates),审阅通过后再正式写入

这意味着翻译质量不再是黑盒——每一条翻译都有迹可循。

自定义框架:支持任意 i18n 方案

这是 i18n Ally Next 最灵活的功能之一。无论你使用什么 i18n 库,甚至是团队自研的方案,都可以通过一个 YAML 配置文件让插件完美支持。

为什么需要自定义框架?

内置框架覆盖了 25+ 主流方案,但现实中总有例外:

  • 公司内部封装的 i18n 工具函数
  • 使用了非标准的翻译函数名(如 __(), lang(), msg()
  • 新兴框架尚未被内置支持
  • 需要同时匹配多种调用模式

如何配置?

在项目根目录创建 .vscode/i18n-ally-next-custom-framework.yml

# 指定生效的文件类型
languageIds:
  - typescript
  - typescriptreact
  - vue

# 正则匹配翻译函数调用,{key} 是占位符
usageMatchRegex:
  - "\\Wt\\(\\s*['\"`]({key})['\"`]"
  - "\\W\\$t\\(\\s*['\"`]({key})['\"`]"
  - "\\Wi18n\\.t\\(\\s*['\"`]({key})['\"`]"

# 提取重构模板,$1 代表键名
refactorTemplates:
  - "t('$1')"
  - "{t('$1')}"

# 命名空间支持
namespace: true
namespaceDelimiter: ":"

# 作用域范围检测(如 React useTranslation hook)
scopeRangeRegex: "useTranslation\\(['\"](.+?)['\"]\\)"

# 是否禁用所有内置框架
monopoly: false

作用域范围是什么?

scopeRangeRegex 是一个非常实用的功能。以 React 为例:

const { t } = useTranslation("settings")

t("title")        // → 自动解析为 "settings.title"
t("theme.dark")   // → 自动解析为 "settings.theme.dark"

插件会根据正则匹配的结果自动划分作用域范围——从匹配位置到下一个匹配位置(或文件末尾)。在作用域内的所有 t() 调用都会自动加上命名空间前缀。

热重载

修改 YAML 配置文件后无需重启 VS Code,插件会自动检测文件变更并重新加载。这让调试正则表达式变得非常方便——改完立刻看效果。

快速上手

安装

在 VS Code 扩展面板搜索 i18n Ally Next,或从以下渠道安装:

最小配置

对于大多数项目,你只需要两步:

1. 指定语言文件路径

// .vscode/settings.json
{
  "i18n-ally-next.localesPaths": ["src/locales"],
  "i18n-ally-next.sourceLanguage": "zh-CN"
}

2. 打开项目,开始使用

插件会自动检测你的 i18n 框架(Vue I18n、react-i18next 等),无需额外配置。

打开任意包含翻译键的文件,你会看到:

  • 🏷️ 翻译键旁边出现内联注解,直接显示翻译值
  • 🌐 悬停键名可查看所有语言的翻译
  • ✏️ 点击即可编辑翻译
  • 📊 侧边栏显示翻译进度和缺失项

从 i18n Ally 迁移

如果你正在使用原版 i18n Ally,迁移非常简单:

  1. 卸载 i18n Ally
  2. 安装 i18n Ally Next
  3. settings.json 中的 i18n-ally. 前缀替换为 i18n-ally-next.

所有配置项保持兼容,无需其他改动。

写在最后

国际化不应该是痛苦的。i18n Ally Next 的目标是让 i18n 成为开发流程中自然而然的一部分——写代码时看到翻译,提交前检查缺失,源文案变更时自动提醒,协作时有据可查。

我们不只是在做一个插件,更是在构建一套完整的 i18n 开发工具链:从文档到配置,从检测到提取,从翻译到审阅,每一个环节都有对应的解决方案。

如果你觉得这个项目有用,欢迎:

  • ⭐ 在 GitHub 上给我们一个 Star
  • 🐛 提交 Issue 反馈问题
  • 💬 分享给你的团队和朋友
  • 📖 阅读完整文档开始使用

本文同步发布于 i18n Ally Next 官方文档

React 样式——styled-components

作者 随意_
2026年2月14日 10:40

在 React 开发中,样式管理一直是绕不开的核心问题 —— 全局 CSS 命名冲突、动态样式繁琐、样式与组件解耦难等痛点,长期困扰着前端开发者。而 styled-components 作为 React 生态中最主流的 CSS-in-JS 方案,彻底颠覆了传统样式编写方式,将样式与组件深度绑定,让样式管理变得简洁、可维护且灵活。本文将从核心原理、基础语法、进阶技巧到实战场景,全面拆解 styled-components 的使用精髓,涵盖原生标签、自定义组件、第三方组件适配等全场景用法。

一、什么是 styled-components?

styled-components 是一款专为 React/React Native 设计的 CSS-in-JS 库,核心思想是 “将 CSS 样式写在 JavaScript 中,并与组件一一绑定”。它由 Max Stoiber 于 2016 年推出,目前 GitHub 星数超 40k,被 Airbnb、Netflix、Spotify 等大厂广泛采用。

核心优势

  1. 样式封装,杜绝污染:每个样式组件生成唯一的 className,彻底解决全局 CSS 命名冲突问题;
  2. 动态样式,灵活可控:直接通过组件 props 控制样式,无需拼接 className 或写内联样式;
  3. 自动前缀,兼容省心:自动为 CSS 属性添加浏览器前缀(如 -webkit--moz-),无需手动处理兼容;
  4. 语义化强,易维护:样式与组件代码同文件,逻辑闭环,可读性和可维护性大幅提升;
  5. 按需打包,体积优化:打包时自动移除未使用的样式,减少冗余代码;
  6. 通用适配,场景全覆盖:既支持 HTML 原生标签,也兼容自定义组件、第三方 UI 组件(如 KendoReact/Ant Design)。

二、基础语法:从原生 HTML 标签到样式组件

styled-components 的核心语法分为两种形式,分别适配不同场景,是掌握该库的基础。

1. 安装

在 React 项目中安装核心依赖(TypeScript 项目可额外安装类型声明):

# npm
npm install styled-components

# yarn
yarn add styled-components

# TypeScript 类型声明(新版已内置,可选)
npm install @types/styled-components --save-dev

2. 语法形式 1:styled.HTML标签(原生标签快捷写法)

这是最常用的基础语法,styled. 后紧跟 HTML 原生标签名(如 div/button/p/h1/input 等),本质是 styled() 函数的语法糖,用于快速创建带样式的原生 HTML 组件。

多标签示例:覆盖高频 HTML 元素

import React from 'react';
import styled from 'styled-components';

// 1. 布局容器:div
const Container = styled.div`
  width: 90%;
  max-width: 1200px;
  margin: 20px auto;
  padding: 24px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
`;

// 2. 标题:h1/h2
const TitleH1 = styled.h1`
  color: #1f2937;
  font-size: 32px;
  font-weight: 700;
  margin-bottom: 16px;
`;

// 3. 文本:p/span
const Paragraph = styled.p`
  color: #4b5563;
  font-size: 16px;
  line-height: 1.6;
  margin-bottom: 12px;
`;
const HighlightText = styled.span`
  color: #2563eb;
  font-weight: 500;
`;

// 4. 交互:button/a
const PrimaryButton = styled.button`
  padding: 10px 20px;
  background-color: #2563eb;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  &:hover { background-color: #1d4ed8; }
  &:disabled { background-color: #93c5fd; cursor: not-allowed; }
`;
const Link = styled.a`
  color: #2563eb;
  text-decoration: none;
  &:hover { text-decoration: underline; color: #1d4ed8; }
`;

// 5. 表单:input/label
const FormLabel = styled.label`
  display: block;
  font-size: 14px;
  color: #374151;
  margin-bottom: 6px;
`;
const Input = styled.input`
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  &:focus {
    outline: none;
    border-color: #2563eb;
    box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
  }
`;

// 6. 列表:ul/li
const List = styled.ul`
  margin: 16px 0;
  padding-left: 24px;
`;
const ListItem = styled.li`
  margin-bottom: 8px;
  &:last-child { margin-bottom: 0; }
`;

// 使用示例
function BasicTagDemo() {
  return (
    <Container>
      <TitleH1>原生标签样式化示例</TitleH1>
      <Paragraph>
        这是 <HighlightText>styled.p</HighlightText> 渲染的段落,支持 <HighlightText>styled.span</HighlightText> 行内样式。
      </Paragraph>
      <List>
        <ListItem>styled.div:布局容器核心标签</ListItem>
        <ListItem>styled.button:交互按钮,支持 hover/禁用状态</ListItem>
        <ListItem>styled.input:表单输入框,支持焦点样式</ListItem>
      </List>
      <FormLabel htmlFor="username">用户名</FormLabel>
      <Input id="username" placeholder="请输入用户名" />
      <PrimaryButton style={{ marginTop: '10px' }}>提交</PrimaryButton>
      <Link href="#" style={{ marginLeft: '10px' }}>忘记密码?</Link>
    </Container>
  );
}

3. 语法形式 2:styled(组件)(自定义 / 第三方组件适配)

当需要给自定义 React 组件第三方 UI 组件添加样式时,必须使用 styled() 通用函数(styled.xxx 仅支持原生标签)。

核心要求

被包裹的组件需接收并传递 className 属性到根元素(第三方组件库如 KendoReact/AntD 已内置支持)。

示例 1:给自定义组件加样式

import React from 'react';
import styled from 'styled-components';

// 自定义组件:必须传递 className 到根元素
const MyButton = ({ children, className }) => {
  // 关键:将 className 传给根元素 <button>,样式才能生效
  return <button className={className}>{children}</button>;
};

// 用 styled() 包裹自定义组件,添加样式
const StyledMyButton = styled(MyButton)`
  background-color: #28a745;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  &:hover { background-color: #218838; }
`;

function CustomComponentDemo() {
  return <StyledMyButton>自定义组件样式化</StyledMyButton>;
}

示例 2:给第三方组件(KendoReact)加样式

import React from 'react';
import styled from 'styled-components';
// 引入 KendoReact 按钮组件
import { Button } from '@progress/kendo-react-buttons';

// 用 styled() 覆盖第三方组件默认样式
const StyledKendoButton = styled(Button)`
  background-color: #dc3545 !important; /* 覆盖组件内置样式 */
  border-color: #dc3545 !important;
  color: white !important;
  padding: 8px 16px !important;
  
  &:hover {
    background-color: #c82333 !important;
  }
`;

function ThirdPartyComponentDemo() {
  return <StyledKendoButton>自定义样式的 KendoReact 按钮</StyledKendoButton>;
}

4. 两种语法的关系

styled.xxxstyled('xxx') 的语法糖(如 styled.div = styled('div')),仅简化原生标签的写法;而 styled(组件) 是通用方案,覆盖所有组件类型,二者底层均基于 styled-components 的样式封装逻辑。

三、进阶技巧:提升开发效率与可维护性

掌握基础语法后,这些进阶技巧能适配中大型项目的复杂场景。

1. 动态样式:通过 Props 控制样式

这是 styled-components 最核心的特性之一,无需拼接 className,直接通过 props 动态调整样式,适配状态切换、主题变化等场景。

jsx

import React from 'react';
import styled from 'styled-components';

// 带 props 的动态按钮
const DynamicButton = styled.button`
  padding: ${props => props.size === 'large' ? '12px 24px' : '8px 16px'};
  background-color: ${props => {
    switch (props.variant) {
      case 'primary': return '#2563eb';
      case 'danger': return '#dc3545';
      case 'success': return '#28a745';
      default: return '#6c757d';
    }
  }};
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  &:hover { opacity: 0.9; }
`;

function DynamicStyleDemo() {
  return (
    <div style={{ gap: '10px', display: 'flex', padding: '20px' }}>
      <DynamicButton variant="primary" size="large">主要大按钮</DynamicButton>
      <DynamicButton variant="danger">危险默认按钮</DynamicButton>
      <DynamicButton variant="success">成功按钮</DynamicButton>
    </div>
  );
}

2. 样式继承:复用已有样式

基于已定义的样式组件扩展新样式,避免重复代码,提升复用性。

import styled from 'styled-components';

// 基础按钮(通用样式)
const BaseButton = styled.button`
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  color: white;
  cursor: pointer;
  font-size: 14px;
`;

// 继承基础按钮,扩展危险按钮样式
const DangerButton = styled(BaseButton)`
  background-color: #dc3545;
  &:hover { background-color: #c82333; }
`;

// 继承并覆盖样式:轮廓按钮
const OutlineButton = styled(BaseButton)`
  background-color: transparent;
  border: 1px solid #2563eb;
  color: #2563eb;
  &:hover {
    background-color: #2563eb;
    color: white;
    transition: all 0.2s ease;
  }
`;

3. 全局样式:重置与全局配置

通过 createGlobalStyle 定义全局样式(如重置浏览器默认样式、设置全局字体),只需在根组件中渲染一次即可生效。

import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';

// 全局样式组件
const GlobalStyle = createGlobalStyle`
  /* 重置浏览器默认样式 */
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  /* 全局字体和背景 */
  body {
    font-family: 'Microsoft YaHei', sans-serif;
    background-color: #f8f9fa;
    color: #333;
  }

  /* 全局链接样式 */
  a {
    text-decoration: none;
    color: #2563eb;
  }
`;

// 根组件中使用
function App() {
  return (
    <>
      <GlobalStyle /> {/* 全局样式生效 */}
      <div>应用内容...</div>
    </>
  );
}

4. 主题管理(ThemeProvider):全局样式统一

在中大型项目中,通过 ThemeProvider 统一管理主题(主色、副色、字体),支持主题切换(如浅色 / 暗黑模式)。

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components';

// 定义主题对象
const lightTheme = {
  colors: { primary: '#2563eb', background: '#f8f9fa', text: '#333' },
  fontSize: { small: '12px', medium: '14px' }
};
const darkTheme = {
  colors: { primary: '#198754', background: '#212529', text: '#fff' },
  fontSize: { small: '12px', medium: '14px' }
};

// 使用主题样式
const ThemedCard = styled.div`
  padding: 20px;
  background-color: ${props => props.theme.colors.background};
  color: ${props => props.theme.colors.text};
  border-radius: 8px;
`;
const ThemedButton = styled.button`
  padding: 8px 16px;
  background-color: ${props => props.theme.colors.primary};
  color: white;
  border: none;
  border-radius: 4px;
`;

function ThemeDemo() {
  const [isDark, setIsDark] = useState(false);
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <div style={{ padding: '20px' }}>
        <button onClick={() => setIsDark(!isDark)}>
          切换{isDark ? '浅色' : '暗黑'}主题
        </button>
        <ThemedCard style={{ marginTop: '10px' }}>
          <ThemedButton>主题化按钮</ThemedButton>
        </ThemedCard>
      </div>
    </ThemeProvider>
  );
}

5. 嵌套样式:模拟 SCSS 语法

支持样式嵌套,贴合组件 DOM 结构,减少选择器冗余。

const Card = styled.div`
  width: 300px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;

  /* 嵌套子元素样式 */
  .card-title {
    font-size: 20px;
    margin-bottom: 10px;
  }
  .card-content {
    font-size: 14px;
    /* 深层嵌套 */
    .highlight { color: #2563eb; }
  }
`;

四、实战场景:什么时候用 styled-components?

  1. 中大型 React 项目:需要严格样式封装,避免多人协作时的样式冲突;
  2. 动态样式频繁的场景:如按钮状态切换、主题切换、响应式布局;
  3. 组件库开发:样式与组件逻辑内聚,便于组件发布和复用;
  4. 第三方组件库定制:覆盖 KendoReact/AntD 等组件的默认样式,精准且不污染全局;
  5. 响应式开发:通过媒体查询快速适配不同屏幕尺寸,样式与组件同文件更易维护。

五、注意事项与最佳实践

  1. 避免过度嵌套:嵌套层级建议不超过 2-3 层,否则可读性下降;
  2. 自定义组件必传 className:用 styled(组件) 时,确保组件将 className 传给根元素;
  3. 慎用!important:覆盖第三方组件样式时,优先提高选择器优先级,而非直接用 !important
  4. 样式组件定义在外部:避免在渲染函数内定义样式组件(导致每次渲染重新创建);
  5. 调试优化:安装 babel-plugin-styled-components 插件,让开发者工具显示有意义的 className;
  6. 抽离通用样式:将重复样式抽离为基础组件或主题变量,减少冗余。

六、总结

styled-components 并非简单的 “CSS 写在 JS 里”,而是 React 组件化思想在样式领域的延伸。其核心价值在于:

  1. 语法灵活styled.xxx 适配原生标签,styled(组件) 适配自定义 / 第三方组件,覆盖全场景;
  2. 样式闭环:样式与组件绑定,杜绝全局污染,提升可维护性;
  3. 动态能力:通过 props 和 ThemeProvider 轻松实现动态样式和主题管理;
  4. 生态兼容:无缝对接 KendoReact/AntD 等主流组件库,降低定制成本。

对于 React 开发者而言,掌握 styled-components 不仅能解决传统样式方案的痛点,更能构建出更健壮、易扩展的组件体系,是中大型 React 项目样式管理的首选方案。

OpenSpec 和 Spec-Kit 踩了 27 个坑之后,于是我写了个 🔥SuperSpec🔥 一次性填平

作者 像颗糖
2026年2月13日 19:21

SuperSpec

npm version npm downloads license node version

AI 编码助手的规格驱动开发 (SDD) 工具。

在线文档 · 仓库地址

为什么需要 SuperSpec?

AI 编码助手很强大,但需求模糊时容易产出不一致、无文档的代码。

当前支持的 AI 助手: CursorClaude CodeQwen 通义OpenCodeCodexCodeBuddyQoder。任何能读取 AGENTS.md 的编辑器均可使用本工作流。使用 superspec init --ai cursor|claude|qwen|opencode|codex|codebuddy|qoder 可安装对应编辑器的规则与斜杠命令(默认:cursor)。

OpenSpec 痛点

# OpenSpec 痛点 SuperSpec 解决方案
1 无 spec 大小控制 — spec 无限膨胀,吞噬 AI 上下文窗口 第一性原理 + lint(目标 300 / 硬限 400 行),/ss-specs 自动拆分
2 验证不一致 — validate --strict 通过但 archive 失败 统一验证管线:lintvalidatechecklistarchive
3 无实现↔spec 校验 — 编码后 spec 漂移 sync 收集 git diff 到 context.md/ss-resume 与 spec 交叉比对
4 无 上下文恢复流程 — 切换 AI 对话后上下文丢失 sync + context.md + /ss-resume 在新会话中恢复完整 spec 上下文
5 无 spec 间依赖管理 depends_on frontmatter + deps add/deps list/deps remove
6 无跨 spec 和归档搜索 search 支持 --archived--artifact--regex 过滤
7 无进度追踪或状态可视化 status 展示所有变更及各 artifact 状态(Draft → Ready → Done)
8 单一模式 — 简单修复和大功能同等开销 标准模式(轻量)vs 增强模式(完整 US/FR/AC + checklist)
9 无项目级 AI 上下文规则配置 superspec.config.json 支持 strategycontextlimitsbranchTemplate
10 无交叉引用验证(US↔FR↔AC↔tasks) validate --check-deps 确保完整追溯链
11 无国际化 — 仅英文 --lang zh|en,完整中文模板 + CLI 提示
12 无任务粒度控制 增强模式:每个任务 < 1 小时,分阶段 + 并行标记 [P]
13 无法自动创建分支 — 变更分支命名不统一 superspec create 根据 branchTemplate 自动创建 git 分支,支持 branchPrefix / branchTemplate / changeNameTemplate 自定义

Spec-Kit 痛点

# Spec-Kit 痛点 SuperSpec 解决方案
1 命令占用大量 token,严重挤占上下文窗口 Slash 命令为文件模板,按需加载(零空闲开销)
2 制造"工作幻觉" — 生成大量无用文档 第一性原理:每句话必须提供决策信息,信噪比优先
3 无法更新/迭代已有 spec — 总是创建新分支 原地 spec 演进:直接编辑 proposal/spec/tasks,/ss-clarify 迭代
4 忽略现有项目结构和约定 strategy: follow 读取 context 文件作为约束,匹配现有模式
5 自动生成大量无用测试 不自动生成测试 — 任务验证由开发者控制
6 不适合增量开发 / 小任务 标准模式处理快速功能;仅需要时启用增强模式(-b
7 Python 安装(uv tool)— 与 JS/TS 生态不匹配 npm/pnpm/yarn 安装,原生 Node.js 生态
8 无 spec 间依赖管理 depends_on + deps add/deps list + 依赖图
9 无 上下文恢复流程 synccontext.md/ss-resume 无缝续接
10 在子目录初始化会失败 任意位置可用 — superspec.config.json 在项目根目录,specDir 可配置
11 无 spec 归档及上下文保留 archive 归档已完成变更,search --archived 仍可检索
12 与最新 AI 工具升级不兼容 编辑器无关的 AGENTS.md + --ai 标志安装对应编辑器规则
13 模式单一且配置僵硬 — 无法在轻量与增强模式间自由切换 SuperSpec 支持标准模式与增强模式自由切换,superspec.config.json 中的 booststrategybranchTemplate 等提供高度定制化配置
14 无创造/探索模式 strategy: create-c)允许提出新架构方案并记录权衡

安装

# npm
npm install -g @superspec/cli

# pnpm
pnpm add -g @superspec/cli

# yarn
yarn global add @superspec/cli

需要 Node.js >= 18.0.0

快速开始

cd your-project

superspec init                  # 默认(英文模板)
superspec init --lang zh        # 中文模板
superspec init --ai claude      # 指定 AI 助手类型(cursor|claude|qwen|opencode|codex|codebuddy|qoder)
superspec init --force          # 强制覆盖已有配置
superspec init --no-git         # 跳过 git 初始化

核心流程

标准模式:  create (proposal → checklist ✓) → tasks → apply → [vibe: sync → resume] → archive
增强模式:  create -b (proposal → spec → [自动: 拆分? design?] → checklist ✓) → tasks → apply → ...

标准模式:AI 生成 proposal.md(需求 + 技术方案)→ 自动 checklist(/10)→ tasks.md — 适合简单功能和 bug 修复。

增强模式:AI 生成 proposal.md(需求背景)→ spec.md(US/FR/AC 细节)→ 自动复杂度评估(拆分?design?)→ 自动 checklist(/25)→ tasks.md — 适合大功能、需要设计评审和交叉验证的场景。

Vibe coding 阶段apply 之后,用 sync 收集 git 变更,用 /ss-resume 在新 AI 对话中恢复上下文。

Slash 命令(AI Agent)

这些是你与 AI 助手交互的主要命令,直接在 AI 对话中输入即可:

主流程

命令 标志 功能
/ss-create <feature> -b 增强, -c 创造, -d <desc>, --no-branch, --spec-dir <dir>, --branch-prefix <prefix>, --branch-template <tpl>, --change-name-template <tpl>, --intent-type <type>, --user <user>, --lang <lang> 创建文件夹 + 分支,AI 按需生成 proposal(boost: + spec + design)+ 自动 checklist 质量门
/ss-tasks 从 proposal 生成任务清单
/ss-apply 逐个执行任务
/ss-resume 恢复 spec 上下文(运行 sync → 读取 context.md)
/ss-archive [name] --all 归档已完成的变更

质量与发现

命令 模式 标志 功能
/ss-clarify 通用 澄清歧义、记录决策
/ss-checklist 通用 质量门:标准模式(/10,proposal 后)或增强模式(/25,spec 后)。/ss-create 自动调用
/ss-lint [name] 通用 检查 artifact 大小
/ss-validate [name] 增强 --check-deps 交叉引用一致性检查(US↔FR↔AC↔tasks)
/ss-status 通用 查看所有变更状态
/ss-search <q> 通用 --archived, --artifact <type>, --limit <n>, -E/--regex 全文搜索
/ss-link <name> 通用 --on <other> 添加 spec 依赖
/ss-deps [name] 通用 查看依赖图

使用示例

你:   /ss-create 添加用户认证 @jay
AI:   → 执行 `superspec create addUserAuth --intent-type feature`
      → 生成 proposal.md(含需求 + 技术方案)
      → 自动执行 checklist(/10)→ 通过
      → 提示执行 /ss-tasks

你:   /ss-tasks
AI:   → 读取 proposal.md → 生成分阶段任务

你:   /ss-apply
AI:   → 逐个执行任务,每个完成后标记 ✅

你:   /ss-resume    (新对话 / 中断后继续)
AI:   → 运行 sync → 读取 context.md → 从上次中断处继续

CLI 命令

初始化

superspec init

初始化 SuperSpec 到当前项目。

superspec init                  # 默认(英文模板)
superspec init --lang zh        # 中文模板
superspec init --ai claude      # 指定 AI 助手类型(cursor|claude|qwen|opencode|codex|codebuddy|qoder)
superspec init --force          # 强制覆盖已有配置
superspec init --no-git         # 跳过 git 初始化

核心流程

superspec create <feature>

创建变更文件夹和 git 分支。Artifact 由 AI 通过 /ss-create 按需生成。

superspec create add-dark-mode                              # 标准模式
superspec create add-auth -b                                # 增强模式(spec(支持拆分子 spec )+ design + checklist)
superspec create redesign-ui -c                             # 创造模式(探索新方案)
superspec create new-arch -b -c --no-branch                 # 增强 + 创造 + 不创建分支
superspec create add-auth -d "OAuth2 集成"                   # 附带描述
superspec create add-auth --spec-dir specs                  # 自定义 spec 文件夹
superspec create add-auth --branch-prefix feature/          # 自定义分支前缀
superspec create add-auth --branch-template "{prefix}{date}-{feature}-{user}"    # 自定义分支名模板
superspec create add-auth --change-name-template "{date}-{feature}-{user}"       # 自定义文件夹名模板
superspec create add-auth --intent-type hotfix              # 意图类型(feature|hotfix|bugfix|refactor|chore)
superspec create add-auth --user jay                        # 开发者标识
superspec create add-auth --lang zh                         # SDD 文档语言(en|zh)
superspec archive [name]

归档已完成的变更。

superspec archive add-auth      # 归档指定变更
superspec archive --all         # 归档所有已完成的变更
superspec update

刷新 agent 指令和模板到最新版本。

superspec update

质量与验证

superspec lint [name]

检查 artifact 行数是否超限。

superspec lint add-auth         # 检查指定变更
superspec lint                  # 检查所有活跃变更
superspec validate [name]

交叉验证 artifact 一致性(US↔FR↔AC↔tasks)。

superspec validate add-auth                 # 验证指定变更
superspec validate add-auth --check-deps    # 同时检查依赖一致性
superspec validate                          # 验证所有活跃变更

搜索与发现

superspec search <query>

全文搜索所有变更内容。

superspec search "JWT 认证"                          # 搜索活跃变更
superspec search "登录流程" --archived                # 包含已归档变更
superspec search "refresh token" --artifact tasks    # 按 artifact 类型过滤(proposal|spec|tasks|clarify|checklist)
superspec search "认证" --limit 10                   # 限制结果数量(默认: 50)
superspec search "user\d+" -E                        # 使用正则表达式匹配
superspec status

查看所有活跃变更及其 artifact 状态。

superspec status

依赖管理

superspec deps add <name>
superspec deps add add-auth --on setup-database
superspec deps remove <name>
superspec deps remove add-auth --on setup-database
superspec deps list [name]
superspec deps list add-auth    # 查看指定变更的依赖
superspec deps list             # 查看所有依赖关系

Vibe Coding(SDD 后阶段)

superspec sync [name]

生成/刷新 context.md,包含 git diff 信息(零 AI token — 纯 CLI 操作)。

superspec sync add-auth                 # 同步指定变更
superspec sync add-auth --base develop  # 指定基准分支
superspec sync add-auth --no-git        # 跳过 git diff 收集
superspec sync                          # 同步所有活跃变更

策略:follow vs create

每个变更有 strategy 字段控制 AI 的实现方式:

follow(默认) create-c
读取项目规则 是,作为约束 是,作为参考
架构 必须对齐现有架构 可以提出替代方案
文件结构 匹配现有模式 可以引入新模式
适用场景 常规功能、bug 修复 重构、新模块、UX 创新

superspec.config.json 中配置项目规则文件:

{
  "context": [".cursor/rules/coding-style.mdc", "AGENTS.md", "docs/conventions.md"]
}

第一性原理

灵感来源于 LeanSpec

# 原则 规则
I 上下文经济 每个 artifact < 300 行,硬限 400 行
II 信噪比 每个句子必须提供决策信息
III 意图优于实现 关注为什么和什么,不关注怎么做
IV 渐进式披露 从最小开始,仅在需要时扩展
V 必备内容 元数据、问题、方案、成功标准、权衡

配置

superspec init 生成 superspec.config.json

字段 默认值 说明
lang "en" 模板语言(zh / en),同时控制 CLI 提示语言
specDir "superspec" Spec 文件夹名
branchPrefix "spec/" Git 分支前缀
boost false 默认启用增强模式
strategy "follow" follow = 遵循项目规则,create = 自由探索
context [] AI 需要读取的项目规则文件
limits.targetLines 300 目标最大行数
limits.hardLines 400 硬限最大行数
archive.dir "archive" 归档子目录
archive.datePrefix true 归档文件夹加日期前缀

项目结构

SuperSpec/
├── package.json                 # monorepo 根
├── pnpm-workspace.yaml
├── tsconfig.json
└── packages/
    └── cli/                     # @superspec/cli
        ├── package.json
        ├── tsup.config.ts
        ├── src/
        │   ├── index.ts         # 库导出
        │   ├── cli/             # CLI 入口 (commander)
        │   ├── commands/        # create / archive / init / update / lint / validate / search / deps / status / sync
        │   ├── core/            # config / template / frontmatter / lint / validate / context
        │   ├── prompts/         # Agent 规则安装器
        │   ├── ui/              # 终端输出 (chalk)
        │   └── utils/           # fs / git / date / paths / template
        ├── templates/
        │   ├── zh/              # 中文模板
        │   └── en/              # 英文模板
        └── prompts/
            ├── rules.md         # Rules.md 模板
            └── agents.md        # AGENTS.md 模板

技术栈

  • 语言: TypeScript
  • 构建: tsup
  • 包管理: pnpm (monorepo)
  • 运行时: Node.js >= 18
  • 依赖: commander, chalk

开发

pnpm install          # 安装依赖
pnpm build            # 构建
pnpm dev              # 监听模式
pnpm --filter @superspec/cli typecheck   # 类型检查

致谢

License

MIT

我做了个 AI + 实时协作 的 draw.io,免费开源!!

2026年2月13日 17:40

前言

相信各位程序员朋友们一定使用过各种绘图软件吧,比如GitHub上star数量特别高的drawio。我们可以使用drawio来画各种图,比如UML类图,流程图,软件架构图等各种图,甚至可以拿来画简单的产品原型图(对于那些不太熟悉使用AxureRP的人来说)。在这个AI爆火的时代,我就在想能不能用AI来生成drawio可以识别的图表呢,再进一步想,能不能多人同时操作同一个图表也就是多人实时协作呢。于是,我就开发了这款AI驱动+多人实时协作的drawio。

在线体验地址:

www.intellidraw.top

编辑

并且,我直接把完整的前后端项目源代码给开源到GitHub上啦!!!,大家可以自行拉取到本地进行学习,修改。

前端开源地址:

github.com/wangfenghua…

后端开源地址:

github.com/wangfenghua…

接下来肯定是各位程序员朋友们最关心的技术栈啦!

项目技术栈

前端

使用Next.js服务端渲染技术 + Ant Design组件库 + yjs + ws + 内嵌的drawio编辑器

Next.js天然对SEO友好,使用蚂蚁金服开源的Ant Design组件库简化样式的编写,使用yjs+WebSocket实现实时协作编辑图表功能。

后端

当然是使用Java开发啦! 并使用一个Node.js微服务来处理实时协作逻辑

后端采用jdk21 + Spring Boot(SSM) + Spring AI + Spring Security + Node.js实现

Spring Boot后端负责处理整个系统主要的业务逻辑,Spring AI 为系统提供AI能力,并使用工厂模式可以使用多种不同的llm,包括系统内置的和用户自定义的。 Spring Security负责处理基于RBAC的权限校验,包括协作房间的用户权限和团队空间的用户权限。由于Java对yjs的支持并不友好,所以直接引入一个Node.js来处理实时协作逻辑,Spring Boot暴露鉴权接口供Node.js对连接进行鉴权。

项目主要功能

1、AI生成Drawio图表

一句话生成你想要的图表  

编辑

这样,不管是想要画什么图表,直接一句话,使用自然语言就能拿到自己想要的图表,并且可以直接导出自己想要的格式,比如SVG,或者PNG。

编辑

⭐⭐⭐实时协作

可以直接在图表编辑页面点击右上角的协作按钮开启协作。系统会自动创建协作房间。

编辑

这里会通过ws连接后端Node.js服务,从而实现实时协作逻辑。比使用Spring Boot的WebSocket透穿yjs的二进制update数据性能更优,支持高并发。

并且也可以管理房间内的成员,比如修改权限等等,前提是私密的房间。如果是公开的房间就不需要进行房间成员的管理了。、

编辑

编辑

团队空间

本项目有公共空间和团队空间之分,所谓公共空间就比如你创建了一个图表到公共空间里面,那么所有的人都能在图表广场看到你所创建的图表,除非你创建一个私有空间或者是团队空间。

编辑

编辑

并且团队空间分为普通版专业版和旗舰版三个等级,区别就在于可以创建的图表数量不同,旗舰版最多。

同时团队空间也是基于RBAC的权限控制的。

编辑

编辑

同时可以编辑团队空间内的图表和空间信息(管理员),也可以在本团队空间之内创建图表。

也可以通过用户id邀请其他用户加入到本团队空间内。在空间管理页面也分为我创建的空间和我加入的空间。

空间管理

编辑

协作房间管理

编辑

图表管理

编辑

开源与贡献

各位大佬可以在GitHub提交PR。

或者是将完整的前后端项目拉取到本地运行

后端的配置文件格式如下:

spring:
  application:
    name: drawio-backend
  mail:
    host:   # 您的SMTP服务器地址
    port:                   # 您的SMTP服务器端口
    username:  # 您的邮箱账户
    password:     # 您的邮箱密码或授权码
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: 
            client-secret: 
            scope: read:user,user:email
            redirect-uri: 
            client-name: Intellidraw 智能绘图
            provider: github
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: login
  ai:
    custom:
      models:
        moonshotai:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        deepseek:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        glm:
          api-key: 
          model: glm-4.6
        qwen:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        duobao:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
    openai:
      api-key: 
      base-url: 
      chat:
        options:
          model: 
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    username: 
    url: 
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver
    # druid 连接池管理
    druid:
      # 初始化时建立物理连接的个数
      initial-size: 5
      # 最小连接池数量
      min-idle: 5
      # 最大连接池数量
      max-active: 20
      # 获取连接等待超时的时间
      max-wait: 60000
      # 一个连接在池中最小的生存的时间
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 30000
      validation-query: select 'x'
      test-on-borrow: false
      test-on-return: false
      pool-prepared-statements: false
      filters: stat,wall,slf4j
      max-pool-prepared-statement-per-connection-size: -1
      use-global-data-source-stat: true
      connection-properties: 'druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000'

server:
  port: 8081
  servlet:
    context-path: /api
rustfs:
  client:
    endpoint: 
    access-key: 
    acess-secret: 
    bucket-name: 


management:
  endpoints:
    web:
      exposure:
        include: health, prometheus
  metrics:
    distribution:
      percentiles:
        http:
          server:
            requests: 0.5, 0.75, 0.9, 0.95, 0.99
  1. Fork 仓库 ➜ 点击 GitHub 右上角 Fork 按钮。
  2. 创建分支 ➜ 推荐使用有意义的分支名
  3. 提交代码 ➜ 确保代码可读性高,符合规范。
  4. 提交 Pull Request(PR) ➜ 详细描述您的更改内容,并关联相关 issue(如有)。
  5. 等待审核 ➜ 维护者会进行代码审核并合并。

以上讲解如果对你有帮助,不妨给我的项目点个小小的 star 🌟,成为一下我的精神股东呢

Mokup:构建工具友好的可视化 Mock 工具

作者 icebreaker
2026年2月13日 14:40

devio-cover.jpg

Mokup:构建工具友好的可视化 Mock 工具

大家好呀,我是 icebreaker,一名前端开发者兼开源爱好者。

马上就过年了,在这个特别的时间点,我先祝大家:新年快乐!身体健康!工作顺利!来年发大财!


当然,回归正题,这里也向大家介绍一下我最近做的一个开源项目:Mokup,一个基于文件路由的 HTTP Mock 工具。

我做这个当时的目的,主要是给我团队里的同学用的,想让大家最低成本地在现有前端工程里接入服务端的能力,让大家循序渐进的成为全栈,这样才能在AI时代立足。

项目地址:GitHub , 官网与文档

Mokup 是什么

Mokup 是一个基于文件路由的 HTTP Mock 工具。你把 mock 文件放在 mock/ 目录里,它会自动生成可匹配的路由并提供响应。

它的目标很直接:让 mock 在你已有的前端工程里尽快跑起来,减少“为了联调再造一套服务”的成本。

有什么特性

  • 构建工具友好:Vite / Webpack 都能快速的接入,接入成本极低。
  • 可视化:内置 Playground,路由是否生效一眼可见。
  • 开发体验好:mock 文件和目录配置改完就刷新,不用频繁重启。
  • 能部署到多个环境:本地开发、Node 服务端、Worker、Service Worker 都可用。

为什么要做它

这个实际上也和我自己在我自己的群里,还有公司里的项目组搜集痛点有关,那就是,很多团队的痛点不是“不会写 mock”,而是:

  • 接入步骤多,换个构建工具就要重配一次。
  • 本地排查时看不到全局路由状态,只能翻文件猜。
  • 每改一个 mock 都要重启或手动验证,反馈慢。

Mokup 就是为了解决这三个问题:接入更轻、可视化更强、开发反馈更快。

构建工具友好

Vite 接入

import mokup from 'mokup/vite'

export default {
  plugins: [
    mokup({
      entries: { dir: 'mock', prefix: '/api' },
    }),
  ],
}

然后这时候 你就可以在 mock/ 目录里放 mock 文件了,Mokup 会自动扫描并生成对应的路由。

你也可以在你的 CLI 中快速访问 mokup 的 playground 进行可视化调试

cli.png

Webpack 接入

const { mokupWebpack } = require('mokup/webpack')

const withMokup = mokupWebpack({
  entries: { dir: 'mock', prefix: '/api' },
})

module.exports = withMokup({})

你可以在不改动业务代码结构的情况下,把 mock 能力挂到现有构建流程里。

可视化:Playground(重点)

Mokup 内置 Playground,用来查看当前被扫描到的路由、方法、路径和配置链。

Vite 开发时默认入口:

http://localhost:5173/__mokup

在线体验 Demo:mokup.icebreaker.top/__mokup/

playground.png

它解决的是一个非常实际的问题: 接口不生效时,你不用到处去找问题,只要打开页面就能看到“有没有被扫到、有没有被禁用、匹配到了什么配置”。

开发体验:哪些文件会热更新

在 Vite dev 下,Mokup 会监听 mock 目录变化并自动刷新路由表。常见会触发热更新的改动包括:

  • 新增/修改/删除 mock 路由文件,例如:
    • mock/users.get.ts
    • mock/messages.get.json
    • mock/orders/[id].patch.ts
  • 修改目录配置文件:mock/**/index.config.ts
  • 调整目录结构(移动、重命名、创建子目录)

改完后 Playground 会自动刷新路由列表,调试链路更短。

如果你不需要监听,可以在 entries 里配置 watch: false

快速示例:从写文件到看到结果

// mock/users.get.ts
import { defineHandler } from 'mokup'

export default defineHandler({
  handler: c => c.json([{ id: 1, name: 'Ada' }]),
})

启动 dev 后访问 /api/users (你设置了 prefix: '/api' ),即可拿到 mock 数据。

mock-dir.png

快速集成 @faker-js/faker

@faker-js/faker 是我们造假数据最常使用的库了,这里也可以很好的和它集成

Mokup 的 handler 本质上就是 TS/JS 函数,所以能直接接入 @faker-js/faker 这类 mock 数据库,不需要额外适配层。

下面这个示例会根据查询参数 size 返回一组用户列表:

// mock/users.get.ts
import { faker } from '@faker-js/faker'
import { defineHandler } from 'mokup'

export default defineHandler((c) => {
  const size = Number(c.req.query('size') ?? 10)
  const count = Number.isNaN(size) ? 10 : Math.min(Math.max(size, 1), 50)
  const list = Array.from({ length: count }, () => ({
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    city: faker.location.city(),
    createdAt: faker.date.recent({ days: 30 }).toISOString(),
  }))

  return c.json({
    list,
    total: 200,
    page: 1,
    pageSize: count,
  })
})

这对列表页、搜索页、详情页联调都很实用。 如果你希望测试结果可复现,可以在 handler 顶部加上 faker.seed(123)

可部署到多个环境

这套 mock 可以运行在多个环境:比如在 Node.js 里直接使用,甚至还能部署到 Cloudflare Worker。

Node.js 直接使用示例:

import { createFetchServer, serve } from 'mokup/server/node'

const app = await createFetchServer({ entries: { dir: 'mock' } })
serve({ fetch: app.fetch, port: 3000 })

部署到 Cloudflare Worker 示例:

import { createMokupWorker } from 'mokup/server/worker'
import mokupBundle from 'virtual:mokup-bundle'

export default createMokupWorker(mokupBundle)

提示:virtual:mokup-bundle 仅在 Vite 与 @cloudflare/vite-plugin 集成环境可用;Node.js Dev 模式可直接使用 createFetchServer,无需该虚拟模块。

核心架构

core-zh.jpg

适用场景与边界

适合:

  • 已有 Vite/webpack 工程,想低成本接入 mock 的团队
  • 需要可视化路由排查能力的项目
  • 重视开发反馈速度,希望 mock 修改后立即可见的场景

不太适合:

  • 主要依赖复杂动态代理链路的场景
  • 完全不希望引入构建期/插件能力的极轻量脚本方案

Mokup 不是为了替代所有 mock 方案,而是让 mock 更快接入、更好调试、更贴近日常开发流程。

AI 友好性

都已经在这个时代了,怎么能不用 AI 呢, Mokup 当时设计就是考虑到 AI 时代的开发者需求的,所以在设计上也做了一些 AI 友好的考虑,

毕竟时代变了,我们也像纺纱机的工人那样,换到了蒸汽纺纱机,每天奴役 AI 24小时帮我们打工,爽是真的爽,验证问题效率极高。

而且很多开发是懒得写文档的,现在文档什么完全不是问题,就像这篇文章,大部分也是由AI生成的。

结语

Mokup 目前还在快速迭代中,欢迎大家试用并提反馈!无论是功能需求、使用体验还是文档改进都非常欢迎。

如果你也有类似的痛点,或者对 mock 工具有什么想法,也欢迎在评论区交流!我们一起让前端 mock 更好用、更高效!

有志同道合的小伙伴,也可以访问我的 Github 主页联系我一起交流!

Next.js 16 + Supabase + Vercel:SmartChat 全栈 AI 应用架构实战

作者 梦里寻码
2026年2月13日 01:19

前言

全栈 AI 应用怎么选技术栈?这个问题没有标准答案,但 SmartChat 的选择——Next.js 16 + Supabase + Vercel——是一套经过验证的高效组合。本文从架构角度拆解这套技术栈的设计思路。

🔗 项目地址:smartchat.nofx.asia/

微信图片_20260212194724_46_236.png

一、为什么是 Next.js 16?

SmartChat 使用 Next.js 16 的 App Router,充分利用了以下特性:

Server Components: 仪表盘页面使用 Server Components 直接在服务端查询数据库,减少客户端 JS 体积。

API Routes(Serverless): 所有后端逻辑通过 API Routes 实现,无需维护独立的后端服务。

src/app/api/
├── chat/          # 聊天接口(SSE 流式)
├── bots/          # 机器人 CRUD
├── upload/        # 文档上传与向量化
└── conversations/ # 对话管理

Turbopack: 开发环境使用 Turbopack,热更新速度显著提升。

关键优势:前后端同仓库、同语言(TypeScript)、同部署,极大降低了开发和运维复杂度。

二、Supabase:不只是数据库

SmartChat 用 Supabase 承担了多个角色:

2.1 PostgreSQL 数据库 + pgvector

-- 业务数据和向量数据在同一个库
CREATE TABLE document_chunks (
  id uuid PRIMARY KEY,
  bot_id uuid REFERENCES bots(id) ON DELETE CASCADE,
  content text,
  embedding vector(512),  -- pgvector 向量列
  metadata jsonb
);

-- IVFFlat 索引加速向量检索
CREATE INDEX ON document_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

2.2 用户认证(Supabase Auth)

内置邮箱/密码、OAuth 登录,无需自建认证系统。

2.3 行级安全(RLS)

-- 用户只能访问自己的机器人
CREATE POLICY "Users can only access own bots" ON bots
  FOR ALL USING (auth.uid() = user_id);

-- 匿名访客可以查看公开的机器人配置
CREATE POLICY "Public bot access" ON bots
  FOR SELECT USING (is_public = true);

RLS 确保了多租户数据隔离,同时通过 Service Role 客户端允许匿名访客与机器人对话。

2.4 文件存储

文档上传使用 Supabase Storage,统一管理。

SmartChat Dashboard转存失败,建议直接上传图片文件

三、可嵌入组件设计

SmartChat 的一大亮点是一行代码嵌入任何网站

<script src="https://your-domain.com/embed.js" data-bot-id="xxx"></script>

实现原理:

// embed.js 核心逻辑
(function() {
  const botId = document.currentScript.getAttribute('data-bot-id');

  // 创建 iframe 容器
  const iframe = document.createElement('iframe');
  iframe.src = `https://your-domain.com/chat/${botId}?embed=true`;
  iframe.style.cssText = 'position:fixed;bottom:20px;right:20px;...';

  // 创建触发按钮
  const button = document.createElement('div');
  button.onclick = () => iframe.classList.toggle('visible');

  document.body.appendChild(button);
  document.body.appendChild(iframe);
})();

通过 iframe 隔离样式和脚本,避免与宿主网站冲突。聊天界面的颜色、头像、欢迎语都可以在后台自定义。

微信图片_20260212194756_49_236.png

微信图片_20260212194810_51_236.png

四、SSE 流式响应架构

SmartChat 使用 Server-Sent Events 实现实时流式输出:

// API Route: /api/chat
export async function POST(req: Request) {
  const { message, botId, conversationId } = await req.json();

  // 1. 向量检索相关文档
  const relevantDocs = await searchDocuments(message, botId);

  // 2. 构建带上下文的 Prompt
  const messages = await buildMessages(conversationId, relevantDocs);

  // 3. 流式调用 LLM
  const stream = await streamChat({ provider, model, messages });

  // 4. 返回 SSE 流
  return new Response(
    new ReadableStream({
      async start(controller) {
        let fullResponse = '';
        for await (const chunk of stream) {
          const text = extractText(chunk);
          fullResponse += text;
          controller.enqueue(`data: ${JSON.stringify({ text })}\n\n`);
        }
        // 5. 流结束后,附加来源信息
        controller.enqueue(`data: ${JSON.stringify({
          sources: relevantDocs
        })}\n\n`);
        controller.close();

        // 6. 异步保存完整回复到数据库
        await saveMessage(conversationId, fullResponse, relevantDocs);
      }
    }),
    { headers: { 'Content-Type': 'text/event-stream' } }
  );
}

流程:向量检索 → 构建 Prompt → 流式生成 → 实时推送 → 保存记录。

五、Vercel 一键部署

SmartChat 的 Serverless 架构天然适合 Vercel 部署:

  • 零服务器管理:API Routes 自动变成 Serverless Functions
  • 全球 CDN:静态资源自动分发
  • 自动扩缩容:流量高峰自动扩容,空闲时零成本
  • 环境变量管理:在 Vercel Dashboard 配置 API Keys 等敏感信息

部署流程:Fork 仓库 → 连接 Vercel → 配置环境变量 → 部署完成。

六、性能优化要点

  • React 19 + Turbopack:开发体验和构建速度大幅提升
  • Server Components:减少客户端 JS 体积
  • 流式渲染:用户无需等待完整回复
  • IVFFlat 索引:向量检索毫秒级响应
  • 批量写入:文档分块后每 10 条一批插入,避免超时

总结

Next.js + Supabase + Vercel 这套组合的核心优势是简单:一个仓库、一种语言、一键部署。对于中小团队做 AI 应用,这可能是目前投入产出比最高的技术栈选择。SmartChat 是这套架构的一个完整实践案例。

🔗 项目地址:smartchat.nofx.asia/,MIT 开源协议,支持一键部署到 Vercel。

社区推荐重排技术:双阶段框架的实践与演进|得物技术

作者 得物技术
2026年2月12日 13:48

一、背景

推荐系统典型pipeline

在推荐系统多阶段Pipeline(召回→粗排→精排→重排)中,重排作为最终决策环节,承担着将精排输出的有限候选集(通常为Top 100–500个Item)转化为最优序列的关键职责。数学定义为在给定候选集C={x1,x2,,xn}C = \lbrace x_1,x_2,……,x_n \rbrace与目标列表长度LL,重排的目标是寻找一个排列πP(C,L)\pi^* \in P(C,L),使得全局收益函数最大化。

在推荐系统、搜索排序等AI领域,Pointwise 建模是精排阶段的核心方法,即对每个 Item 独立打分后排序,pointwise 建模范式面临挑战:

  • 多样性约束:精排按 item 独立打分排序 → 高分 item 往往语义/类目高度同质(如5个相似短视频连续曝光)。
  • 位置偏差:用户注意力随位置显著衰减,且不同item对位置敏感度不同。
  • 上下文建模:用户决策是序列行为,而非独立事件。

二、重排架构演进:生成式模型实践

我们的重排系统采用G-E两阶段协同框架:

  • 生成阶段(Generation):高效生成若干高质量候选排列。
  • 评估阶段(Evaluation):对候选排列进行精细化打分,选出全局最优结果。

不考虑算力和耗时的情况下,通过穷举所有排列P(C,L)P(C,L)

生成阶段主要依赖启发式规则、随机扰动 + beamSearch算法生成候选list,双阶段范式存在显著的痛点:

  • 质量-延迟-多样性的“不可能三角”:在实践中,增加生成候选list数一般可以提升最终list的质量,但边际收益递减;优化过程中,我们通过增加多目标、多样性等策略都取得了消费指标的提升,但在候选list达到百量级时,单纯增加候选集对指标的提升,同时还有:
  1. 增加beam width,系统耗时增加,DCG@K提升逐渐减少。

  2. 增加通道数,通道间重叠度逐渐增加,去重list增加逐渐减少。

  • 阶段间目标不一致:
  1. 分布偏移:启发式生成Beam Search输出的Top排列中,20%被评估模型否定,生成阶段搜索效率浪费。

  2. 梯度断层:Beam Search含argmax操作,双阶段无法端到端优化;生成模型无法感知评估反馈,优化方向偏离全局最优。

生成模型优化

生成分为启发式方法和生成式模型方法, 一般认为生成式模型方法要好于启发式方法。生成式模型逐渐成为重排主流范式,主要分为两类:自回归生成模型、非自回归生成模型。

  • 自回归生成:按位置顺序逐个生成物品,第 t 位的预测依赖前 t-1 位已生成结果。
  1. 优点:

a. 序列依赖建模强,天然捕获物品间的顺序依赖。

b. 训练简单稳定,每步使用真实前序作为输入,收敛快。

c. 生成质量高,逐步细化决策,适合长序列精细优化。

  1. 缺点:

a. 推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。

b. 局部最优风险,早期错误决策无法回溯修正,影响整体序列质量。

  • 非自回归生成:一次性预测整个推荐序列的所有位置,各位置预测相互独立。
  1. 优点:

a. 推理速度极快:生成整个序列仅需1次前向传播。

2.缺点:

b.条件独立性假设过强:各位置并行预测,难以显式建模物品间复杂依赖关系。

非自回归模型

为了对齐双阶段一致性,同时考虑线上性能,我们推进了非自回归模型的上线。模型结构如下图:

模型包括Candidates Encoder和Position Encoder,Candidates Encoder是标准的Transformer结构, 用于获取item间的交互信息;Position Encoder额外增加了Cross Attention,期望Position序列同时关注Candidate序列。

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 模型输出:一次性输出 n×L 的位置-物品得分矩阵(n 为候选 item 数,L 为目标列表长度),支持高效并行推理
p^ij=exp(xitj)i=1nexp(xitj)\hat{p}_{ij} = \frac{\exp(\mathbf{x}_i^\top \mathbf{t}_j)}{\sum_{i=1}^n \exp(\mathbf{x}_i^\top \mathbf{t}_j)}
  • 位置感知建模:引入可学习位置嵌入,显式建模“同一 item 在不同位置表现不同”的现象(如首屏效应、位置衰减)。
  • 训练目标:模型使用logloss,让正反馈label序列的生成概率最大, 同时负反馈label序列的生成概率最小:
Llog=i[pijyilog(pij^)+pij(1yi)log(1pij^)]\mathcal{L}_{\log} = -\sum_{i} \big[ p_{ij}y_i \log(\hat{p_{ij}}) + p_{ij}(1-y_i) \log(1-\hat{p_{ij}}) \big]

其中,pijp_{ij}表示位置i上是否展示物品j,yiy_{i}表示位置i上的label。

线上实验及收益:

  • 一期新增了非自回归生成通道,pvctr +0.6%,时长+0.55%。
  • 二期在所有通道排序因子中bagging非自回归模型,pvctr +1.0%,时长+1.13%。

自回归模型

由于条件独立性假设, 非自回归模型对上下文信息建模是不够的,近期我们重点推进了自回归模型的开发。

模型通过Transformer架构建模list整体收益,我们使用单向transformer模拟用户浏览行为的因果性,同时解决自回归生成的暴露偏差问题,保持训练和推理的一致性。结构如下:

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 训练目标:模型使用有序回归loss,在评估多个回合中不同长度的子列表时,能够很好地体现出序列中的增量价值。是用于判断长度为j的子列表是否已经达到i次点击或转化的损失函数。
Li,j(θj)=k=1N([yk<i]log(1pi,j(xk))+[yki]log(pi,j(xk)))L_{i,j}(\theta_j) = -\sum_{k=1}^{N} \left([y_k < i]\log(1-p_{i,j}(x_k)) + [y_k \geq i]\log(p_{i,j}(x_k))\right)

线上模型推理效率优化及实验效果:

自回归生成模型推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。因此,我们在传统自回归生成模型的基础上增加MTP(multi token prediction)结构,突破生成式重排模型推理瓶颈。其核心思想是将传统自回归的单步预测扩展为单步多token联合预测,显著减少生成迭代次数。

自回归生成模型在社区推荐已完成了推全,实验中我们新增了自回归生成模型通道,但不是完全体,仅部分位置生成调用了模型:

  • 一期调用两次模型,每次预测4个位置,pvctr +0.69%,有效vv +0.58%。
  • 二期调用两次模型,每次预测5个位置,pvctr +0.54%,有效vv +0.40%。

三、推理性能优化:端到端生成的效率保障

工程架构

为解决CPU推理模型延迟高、制约业务效果的问题,我们对DScatter模型服务进行升级,引入高性能GPU推理能力,具体方案如下:

  • GPU推理框架集成与升级:
  1. 框架升级:将现有依赖的推理框架升级为支持GPU的高性能服务框架。

  2. 硬件资源引入:引入 NVIDIA L20 等专业推理显卡,为当前的listwise评估模型及自回归生成模型提供专用算力,实现模型推理的硬件加速。

  • DScatter模型服务独立部署与容量提升:
  1. 为解决模型部署效率低与资源竞争问题,将DScatter的模型打分逻辑从现有重排服务中完全解耦,构建并部署独立的 DScatter-Model-Server 集群,从根本上消除与重排服务在CPU、内存等关键资源上的竞争。

模型优化

  • 模型格式转换与加速:

导出为 ONNX 格式,使用 TensorRT 进行量化、层融合、动态张量显存等技术加速推理。

  • Item Embedding缓存:

预计算item静态网络,线上直接查询节省计算量。

  • 自回归生成模型核心优化,KV Cache 复用:

缓存已生成token的KV和attention值,仅计算增量token相关值,避免重复计算。

  • 其他LLM推理加速技术应用落地,例如GQA

四、未来规划:迈向端到端序列生成的下一代重排架构

当前“生成-评估”双阶段范式虽在工程落地性上取得平衡,但其本质仍是局部优化:生成阶段依赖启发式规则或浅层模型生成候选,评估阶段虽能识别优质序列,却无法反向指导生成过程,导致系统能力存在理论上限。为突破这一瓶颈,我们规划构建端到端序列生成(End-to-End Sequence Generation) 架构,将重排从“候选筛选”升级为“序列创造”,直接以全局业务目标(如用户停留时长、互动深度、内容生态健康度)为优化目标。

核心架构设计:

  • 统一生成器:以 Transformer 为基础架构,搭建自回归序列建模能力,采用分层混合生成策略:
  1. 粗粒度并行生成:首层预测序列骨架(如类目分布、内容密度)等。

  2. 细粒度自回归精调:在骨架约束下,自回归生成具体 item,确保局部最优。

  • 序列级Reward Modeling:
  1. 构建多目标 reward 函数:xtr、多样性。

  2. Engagement:基于用户滑动轨迹建模序列累积收益(如滑动深度加权CTR)。

  3. Diversity:跨类目/创作者/内容形式的分布熵。

4.Fairness:冷启内容、长尾创作者曝光保障。

训练范式升级:强化学习与对比学习融合

推进自回归生成模型的架构升级与训练体系重构,引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率。

  • 搭建近线系统,生成高质量list候选,提升系统能力上限:

1.基于 DCG 的列表质量打分:

a. 对每个曝光列表L,计算其 DCG@K作为质量分数:

DCG(L)=j=1Kgain(itemj)log2(j+1)\text{DCG}(L) = \sum_{j=1}^{K} \frac{\text{gain}(item_j)}{\log_2(j + 1)}

其中 gain(item)可定义为:

若点击:+1.0

若互动(点赞/收藏):+1.5

若观看 >5s:+0.8

否则:0

2.构造偏好对:

a.对同一用户在同一上下文下的两个列表 LwL_w(win)和 LlL_l(lose)。

b.若 DCG(Lw)>DCG(Ll)+δDCG(L_w) > DCG(L_l) + \delta(δ 为 margin,如 0.1),则构成一个有效偏好对。

  • 引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率:
  1. 模型结构:

a.使用当前自回归生成模型作为策略模型。

b.固定预训练模型作为参考策略 (即 DPO 中的“旧策略”)。

2.DPO损失:

LDPO(θ)=E(x,yw,yl)D[logσ(β(logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right) \right]
  • 技术价值:
  1. 突破“质量-延迟-多样性”不可能三角:通过序列级优化,在同等延迟下实现质量与多样性双提升。

  2. 为AIGC与推荐融合铺路:端到端生成器可无缝接入AIGC内容,实现“内容生成-序列编排”一体化。

参考文献:

  1. Gloeckle F, Idrissi B Y, Rozière B, et al. Better & faster large language models via multi-token prediction[J]. arXiv preprint arXiv:2404.19737, 2024.
  2. Ren Y, Yang Q, Wu Y, et al. Non-autoregressive generative models for reranking recommendation[C]//Proceedings of the 30th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2024: 5625-5634.
  3. Meng Y, Guo C, Cao Y, et al. A generative re-ranking model for list-level multi-objective optimization at taobao[C]//Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval. 2025: 4213-4218.
  4. Zhao X, Xia L, Zhang L, et al. Deep reinforcement learning for page-wise recommendations[C]//Proceedings of the 12th ACM conference on recommender systems. 2018: 95-103.
  5. Feng Y, Hu B, Gong Y, et al. GRN: Generative Rerank Network for Context-wise Recommendation[J]. arXiv preprint arXiv:2104.00860, 2021.
  6. Pang L, Xu J, Ai Q, et al. Setrank: Learning a permutation-invariant ranking model for information retrieval[C]//Proceedings of the 43rd international ACM SIGIR conference on research and development in information retrieval. 2020: 499-508.

往期回顾

1.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

2.服务拆分之旅:测试过程全揭秘|得物技术

3.大模型网关:大模型时代的智能交通枢纽|得物技术

4.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

5.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

文 /张卓

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

【跨域options】为什么你的跨域 POST 请求被浏览器“吞”了?

作者 stayong
2026年2月11日 23:29

引言

在做跨域请求时,你是否遇到过这种怪事:在 Network 面板里,OPTIONS 预检请求返回了 200 OK,看起来一切正常;但紧随其后的 POST 请求却直接报了 CORS error,甚至点开详情还提示 “Provisional headers are shown”

这到底是浏览器的锅,还是后端的错?今晚我们就来扒开这层皮。

image.png


一、 罪魁祸首:被遗忘的“入场券”

在跨域场景下,浏览器会发起 OPTIONS 预检。如果你在请求中使用了非简单的 Header(如 JSON 的 Content-Type 或链路追踪的 traceparent),浏览器会发起“礼貌询问”:

  • 浏览器问: Access-Control-Request-Headers: content-type, traceparent
  • 服务器答: Access-Control-Allow-Headers: Content-Type, Access-Token, ...

坑点就在这里: 如果服务器的白名单里漏掉了哪怕一个 Header(比如本文案例中的 traceparent),浏览器就会认为这场“商业洽谈”失败了。


二、 为什么 OPTIONS 是绿的,POST 却是红的?

这是最让人困惑的地方。既然“洽谈”失败,为什么 OPTIONS 不直接报错?

  1. OPTIONS 是“获取规则”: 服务器成功处理了你的咨询请求,并如实告知了它的配置。从 HTTP 协议看,咨询过程是完美的,所以返回 200 OK
  2. POST 是“执行意图”: 浏览器拿到服务器的规则后,发现和你手里的“会员卡”(traceparent)不匹配。为了安全,浏览器单方面拦截了 POST 请求,不让它真正发往服务器。
  3. “红色”是浏览器的警告: 那个红色的 POST 记录其实是浏览器生成的“虚拟记录”,目的是告诉你: “你原本想发这个,但我把它按下了。” > 核心结论: OPTIONS 的成功代表“问答过程成功”,不代表“准入结果成功”。

三、 关于 Origin 的安全防线

在排查过程中,我们曾想过:能不能手动在 Headers 里改 Origin

  • 答案是:不行。 在浏览器环境下,OriginForbidden Header,由浏览器强制控制。
  • 为什么? 如果开发者能改 Origin,那么钓鱼网站就能轻易伪装成银行官网,CORS 安全机制将形同虚设。
  • 真相: Origin 的存在不由后端决定,但它的“生死存亡”由后端决定。后端通过校验这个自动带上的 Origin 来决定是否给浏览器发放“过路费”(即 Access-Control-Allow-Origin 响应头)。

四、 避坑指南:如何彻底解决?

1. 后端侧(根治方案)

不要只配置 Origin。如果前端有自定义 Header(如分布式追踪、身份令牌),必须在 Access-Control-Allow-Headers 中显式添加。

  • 代码示例(Node.js):

    JavaScript

    res.header("Access-Control-Allow-Headers", "Content-Type, traceparent, Your-Custom-Header");
    

2. 前端侧(调试技巧)

当你看到 “Provisional headers are shown” 且伴随 CORS error 时:

  • 第一步: 检查 OPTIONS 请求头里的 Access-Control-Request-Headers
  • 第二步: 检查 OPTIONS 响应头里的 Access-Control-Allow-Headers
  • 第三步: 找茬,看谁多了,看谁少了。

结语

Web 开发中,CORS 不是敌人,而是保护数据的保镖。当你理解了浏览器、服务器和安全策略之间的那场“礼貌洽谈”,这些诡异的红色报错就不再是迷雾。

设计模式-行为型

作者 牛奶
2026年2月11日 16:08

设计模式-行为型

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。

什么是行为型

行为型模式(Behavioral Patterns)主要关注对象之间的通信职责分配。这些模式描述了对象之间如何协作共同完成任务,以及如何分配职责。行为型模式不仅关注类和对象的结构,更关注它们之间的相互作用,通过定义清晰的通信机制,解决系统中复杂的控制流问题,使得代码更加清晰、灵活和易于维护。

行为型模式

策略模式(Strategy)

策略模式(Strategy Pattern)定义一系列的算法,把它们一个个封装起来,并使它们可以相互替换。策略模式让算法独立于使用它的客户而变化。

前端中的策略模式场景

  • 表单验证:将不同的验证规则(如非空、邮箱格式、手机号格式)封装成策略,根据需要选择验证策略。
  • 不同业务逻辑处理:例如,根据用户权限(普通用户、VIP、管理员)展示不同的 UI 或执行不同的逻辑。
  • 缓动动画算法:在动画库中,提供多种缓动函数(如 linearease-inease-out)供用户选择。

策略模式-JS实现

// 策略对象
const strategies = {
  "S": (salary) => salary * 4,
  "A": (salary) => salary * 3,
  "B": (salary) => salary * 2
};

// 环境类(Context)
class Bonus {
  constructor(salary, strategy) {
    this.salary = salary;
    this.strategy = strategy;
  }

  getBonus() {
    return strategies[this.strategy](this.salary);
  }
}

// 客户端调用
const bonusS = new Bonus(10000, "S");
console.log(bonusS.getBonus()); // 40000

const bonusA = new Bonus(10000, "A");
console.log(bonusA.getBonus()); // 30000

模板方法模式(Template Method)

模板方法模式(Template Method Pattern)定义一个操作中的算法的骨架,而将一些步骤延迟到子类中实现。

模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

前端中的模板方法模式场景

  • UI组件生命周期VueReact 组件的生命周期钩子(如 componentDidMountcreated)就是模板方法模式的应用。框架定义了组件渲染的整体流程,开发者在特定的钩子中实现自定义逻辑。
  • HTTP请求封装:定义一个基础的请求类,处理通用的配置(如 URL、Headers),子类实现具体的请求逻辑(如 GET、POST 参数处理)。

模板方法模式-JS实现

// 抽象父类:饮料
class Beverage {
  // 模板方法,定义算法骨架
  makeBeverage() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }

  boilWater() {
    console.log("煮沸水");
  }

  pourInCup() {
    console.log("倒进杯子");
  }

  // 抽象方法,子类必须实现
  brew() {
    throw new Error("抽象方法不能调用");
  }

  addCondiments() {
    throw new Error("抽象方法不能调用");
  }
}

// 具体子类:咖啡
class Coffee extends Beverage {
  brew() {
    console.log("用沸水冲泡咖啡");
  }

  addCondiments() {
    console.log("加糖和牛奶");
  }
}

// 具体子类:茶
class Tea extends Beverage {
  brew() {
    console.log("用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("加柠檬");
  }
}

// 客户端调用
const coffee = new Coffee();
coffee.makeBeverage();
// 输出:
// 煮沸水
// 用沸水冲泡咖啡
// 倒进杯子
// 加糖和牛奶

const tea = new Tea();
tea.makeBeverage();
// 输出:
// 煮沸水
// 用沸水浸泡茶叶
// 倒进杯子
// 加柠檬

观察者模式(Observer)

观察者模式(Observer Pattern)定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

前端中的观察者模式场景

  • DOM事件监听document.addEventListener 就是最典型的观察者模式。
  • Promisethen 方法也是一种观察者模式,当 Promise 状态改变时,执行相应的回调。
  • Vue响应式系统Dep(目标)和 Watcher(观察者)实现了数据的响应式更新。
  • RxJS:基于观察者模式的响应式编程库。
  • Event Bus:事件总线。

观察者模式-JS实现

// 目标对象(Subject)
class Subject {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// 观察者对象(Observer)
class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} 收到通知: ${data}`);
  }
}

// 客户端调用
const subject = new Subject();
const observer1 = new Observer("观察者1");
const observer2 = new Observer("观察者2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify("更新数据了!");
// 输出:
// 观察者1 收到通知: 更新数据了!
// 观察者2 收到通知: 更新数据了!

迭代器模式(Iterator)

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

前端中的迭代器模式场景

  • 数组遍历forEachmap 等数组方法。
  • ES6 IteratorSymbol.iterator 接口,使得对象可以使用 for...of 循环遍历。
  • Generators:生成器函数可以生成迭代器。

迭代器模式-JS实现

// 自定义迭代器
class Iterator {
  constructor(items) {
    this.items = items;
    this.index = 0;
  }

  hasNext() {
    return this.index < this.items.length;
  }

  next() {
    return this.items[this.index++];
  }
}

// 客户端调用
const items = [1, 2, 3];
const iterator = new Iterator(items);

while (iterator.hasNext()) {
  console.log(iterator.next());
}
// 输出:1 2 3

// ES6 Iterator 示例
const iterableObj = {
  items: [10, 20, 30],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const item of iterableObj) {
  console.log(item);
}
// 输出:10 20 30

责任链模式(Chain of Responsibility)

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

前端中的责任链模式场景

  • 事件冒泡:DOM 事件在 DOM 树中的冒泡机制就是责任链模式。
  • 中间件ExpressKoaRedux 中的中间件机制。
  • 拦截器Axios 的请求和响应拦截器。

责任链模式-JS实现

// 处理器基类
class Handler {
  setNext(handler) {
    this.nextHandler = handler;
    return handler; // 返回 handler 以支持链式调用
  }

  handleRequest(request) {
    if (this.nextHandler) {
      this.nextHandler.handleRequest(request);
    }
  }
}

// 具体处理器
class HandlerA extends Handler {
  handleRequest(request) {
    if (request === 'A') {
      console.log("HandlerA 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerB extends Handler {
  handleRequest(request) {
    if (request === 'B') {
      console.log("HandlerB 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerC extends Handler {
  handleRequest(request) {
    if (request === 'C') {
      console.log("HandlerC 处理了请求");
    } else {
      console.log("没有处理器处理该请求");
    }
  }
}

// 客户端调用
const handlerA = new HandlerA();
const handlerB = new HandlerB();
const handlerC = new HandlerC();

handlerA.setNext(handlerB).setNext(handlerC);

handlerA.handleRequest('A'); // HandlerA 处理了请求
handlerA.handleRequest('B'); // HandlerB 处理了请求
handlerA.handleRequest('C'); // HandlerC 处理了请求
handlerA.handleRequest('D'); // 没有处理器处理该请求

命令模式(Command)

命令模式(Command Pattern)将一个请求封装为一个对象,从而使用户可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

前端中的命令模式场景

  • 富文本编辑器:执行加粗、斜体、下划线等操作,并支持撤销(Undo)和重做(Redo)。
  • 菜单和按钮:将菜单项或按钮的操作封装成命令对象。

命令模式-JS实现

// 接收者:执行实际命令
class Receiver {
  execute() {
    console.log("执行命令");
  }
}

// 命令对象
class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }

  execute() {
    this.receiver.execute();
  }
}

// 调用者:发起命令
class Invoker {
  constructor(command) {
    this.command = command;
  }

  invoke() {
    console.log("调用者发起请求");
    this.command.execute();
  }
}

// 客户端调用
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker(command);

invoker.invoke();
// 输出:
// 调用者发起请求
// 执行命令

备忘录模式(Memento)

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

前端中的备忘录模式场景

  • 状态管理Redux 等状态管理库的时间旅行(Time Travel)功能。
  • 表单草稿:保存用户输入的表单内容,以便下次恢复。
  • 撤销/重做:编辑器中的撤销和重做功能。

备忘录模式-JS实现

// 备忘录:保存状态
class Memento {
  constructor(state) {
    this.state = state;
  }

  getState() {
    return this.state;
  }
}

// 发起人:需要保存状态的对象
class Originator {
  constructor() {
    this.state = "";
  }

  setState(state) {
    this.state = state;
    console.log(`当前状态: ${this.state}`);
  }

  saveStateToMemento() {
    return new Memento(this.state);
  }

  getStateFromMemento(memento) {
    this.state = memento.getState();
    console.log(`恢复状态: ${this.state}`);
  }
}

// 管理者:管理备忘录
class Caretaker {
  constructor() {
    this.mementos = [];
  }

  add(memento) {
    this.mementos.push(memento);
  }

  get(index) {
    return this.mementos[index];
  }
}

// 客户端调用
const originator = new Originator();
const caretaker = new Caretaker();

originator.setState("状态1");
originator.setState("状态2");
caretaker.add(originator.saveStateToMemento()); // 保存状态2

originator.setState("状态3");
caretaker.add(originator.saveStateToMemento()); // 保存状态3

originator.setState("状态4");

originator.getStateFromMemento(caretaker.get(0)); // 恢复到状态2
originator.getStateFromMemento(caretaker.get(1)); // 恢复到状态3

状态模式(State)

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

前端中的状态模式场景

  • 有限状态机(FSM):例如,Promise 的状态(Pending, Fulfilled, Rejected)。
  • 组件状态管理:例如,一个按钮可能有 loadingdisableddefault 等状态,不同状态下点击行为不同。
  • 游戏开发:角色的不同状态(如站立、奔跑、跳跃、攻击)。

状态模式-JS实现

// 状态接口
class State {
  handle(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 具体状态A
class ConcreteStateA extends State {
  handle(context) {
    console.log("当前是状态A");
    context.setState(new ConcreteStateB());
  }
}

// 具体状态B
class ConcreteStateB extends State {
  handle(context) {
    console.log("当前是状态B");
    context.setState(new ConcreteStateA());
  }
}

// 上下文
class Context {
  constructor() {
    this.state = new ConcreteStateA();
  }

  setState(state) {
    this.state = state;
  }

  request() {
    this.state.handle(this);
  }
}

// 客户端调用
const context = new Context();
context.request(); // 当前是状态A
context.request(); // 当前是状态B
context.request(); // 当前是状态A

访问者模式(Visitor)

访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

前端中的访问者模式场景

  • AST遍历Babel 插件开发中,通过访问者模式遍历和修改 AST(抽象语法树)节点。
  • 复杂数据结构处理:对树形结构或图形结构进行不同的操作(如渲染、序列化、验证)。

访问者模式-JS实现

// 元素类
class Element {
  accept(visitor) {
    throw new Error("抽象方法不能调用");
  }
}

class ConcreteElementA extends Element {
  accept(visitor) {
    visitor.visitConcreteElementA(this);
  }

  operationA() {
    return "ElementA";
  }
}

class ConcreteElementB extends Element {
  accept(visitor) {
    visitor.visitConcreteElementB(this);
  }

  operationB() {
    return "ElementB";
  }
}

// 访问者类
class Visitor {
  visitConcreteElementA(element) {
    console.log(`访问者访问 ${element.operationA()}`);
  }

  visitConcreteElementB(element) {
    console.log(`访问者访问 ${element.operationB()}`);
  }
}

// 客户端调用
const elementA = new ConcreteElementA();
const elementB = new ConcreteElementB();
const visitor = new Visitor();

elementA.accept(visitor); // 访问者访问 ElementA
elementB.accept(visitor); // 访问者访问 ElementB

中介者模式(Mediator)

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

前端中的中介者模式场景

  • MVC/MVVM框架:Controller 或 ViewModel 充当中介者,协调 View 和 Model 之间的交互。
  • 复杂表单交互:例如,选择省份后,城市下拉框需要更新;选择城市后,区县下拉框需要更新。使用中介者统一管理这些联动逻辑。
  • 聊天室:用户之间不直接发送消息,而是通过服务器(中介者)转发。

中介者模式-JS实现

// 中介者
class ChatMediator {
  constructor() {
    this.users = [];
  }

  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }

  sendMessage(message, user) {
    this.users.forEach(u => {
      if (u !== user) {
        u.receive(message);
      }
    });
  }
}

// 用户类
class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    console.log(`${this.name} 发送消息: ${message}`);
    this.mediator.sendMessage(message, this);
  }

  receive(message) {
    console.log(`${this.name} 收到消息: ${message}`);
  }
}

// 客户端调用
const mediator = new ChatMediator();
const user1 = new User("User1");
const user2 = new User("User2");
const user3 = new User("User3");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("大家好!");
// 输出:
// User1 发送消息: 大家好!
// User2 收到消息: 大家好!
// User3 收到消息: 大家好!

解释器模式(Interpreter)

解释器模式(Interpreter Pattern)给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器用来解释语言中的句子。

前端中的解释器模式场景

  • 模板引擎MustacheHandlebars 等模板引擎,解析模板字符串并生成 HTML。
  • 编译器前端:将代码解析为 AST。
  • 数学表达式计算:解析并计算字符串形式的数学表达式。

解释器模式-JS实现

// 抽象表达式
class Expression {
  interpret(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 终结符表达式:数字
class NumberExpression extends Expression {
  constructor(number) {
    super();
    this.number = number;
  }

  interpret(context) {
    return this.number;
  }
}

// 非终结符表达式:加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

// 非终结符表达式:减法
class SubtractExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 客户端调用:计算 10 + 5 - 2
const expression = new SubtractExpression(
  new AddExpression(new NumberExpression(10), new NumberExpression(5)),
  new NumberExpression(2)
);

console.log(expression.interpret()); // 13

项目地址

设计模式-结构型

作者 牛奶
2026年2月11日 16:00

设计模式-结构型

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。

什么是结构型

结构型模式(Structural Patterns)主要关注对象组合。这些模式描述了如何将类或对象结合在一起形成更大的结构,同时保持结构的灵活高效。结构型模式通过继承组合的方式来简化系统的设计,解决对象之间的耦合问题,使得系统更加容易扩展和维护。

适配器模式(Adapter)

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以一起工作。

适配器模式通常用于包装现有的类,以便与新的接口或系统进行交互。

前端中的适配器模式场景

  • 接口数据适配:后端返回的数据结构可能与前端组件需要的数据结构不一致,可以使用适配器模式进行转换。
  • 旧接口兼容:在系统重构或升级时,保持对旧接口的兼容性,使用适配器模式将新接口映射到旧接口。
  • Vue计算属性:Vue 中的 computed 属性可以看作是一种适配器,将原始数据转换为视图需要的数据格式。

适配器模式-JS实现

// 旧接口
class OldCalculator {
  constructor() {
    this.operations = function(term1, term2, operation) {
      switch (operation) {
        case 'add':
          return term1 + term2;
        case 'sub':
          return term1 - term2;
        default:
          return NaN;
      }
    };
  }
}

// 新接口
class NewCalculator {
  add(term1, term2) {
    return term1 + term2;
  }

  sub(term1, term2) {
    return term1 - term2;
  }
}

// 适配器类
class CalculatorAdapter {
  constructor() {
    this.calculator = new NewCalculator();
  }

  operations(term1, term2, operation) {
    switch (operation) {
      case 'add':
        return this.calculator.add(term1, term2);
      case 'sub':
        return this.calculator.sub(term1, term2);
      default:
        return NaN;
    }
  }
}

// 客户端调用
const oldCalc = new OldCalculator();
console.log(oldCalc.operations(10, 5, 'add')); // 15

const newCalc = new NewCalculator();
console.log(newCalc.add(10, 5)); // 15

const adapter = new CalculatorAdapter();
console.log(adapter.operations(10, 5, 'add')); // 15

装饰器模式(Decorator)

装饰器模式(Decorator Pattern动态地给一个对象添加一些额外的职责,而不影响该对象所属类的其他实例。

装饰器模式提供了一种灵活的替代继承方案,用于扩展对象的功能。

前端中的装饰器模式场景

  • 高阶组件(HOC):在 React 中,高阶组件本质上就是装饰器模式的应用,用于复用组件逻辑。
  • 类装饰器:在 ES7 装饰器语法或 TypeScript 中,可以使用装饰器来增强类或类的方法,例如用于日志记录、性能监控、权限控制等。

装饰器模式-JS实现

// 原始对象
class Circle {
  draw() {
    console.log("画一个圆形");
  }
}

// 装饰器基类
class Decorator {
  constructor(circle) {
    this.circle = circle;
  }

  draw() {
    this.circle.draw();
  }
}

// 具体装饰器:添加红色边框
class RedBorderDecorator extends Decorator {
  draw() {
    this.circle.draw();
    this.setRedBorder();
  }

  setRedBorder() {
    console.log("添加红色边框");
  }
}

// 客户端调用
const circle = new Circle();
circle.draw();
// 输出:
// 画一个圆形

const redCircle = new RedBorderDecorator(new Circle());
redCircle.draw();
// 输出:
// 画一个圆形
// 添加红色边框

代理模式(Proxy)

代理模式(Proxy Pattern)为其他对象提供一种代理以控制对这个对象的访问。

代理模式可以在访问对象之前或之后执行额外的操作,如权限验证、延迟加载、缓存等。

前端中的代理模式场景

  • 数据响应式Vue 3 使用 Proxy 对象来实现数据的响应式系统,拦截对象的读取和修改操作。
  • 网络请求代理:在开发环境中,配置代理服务器(如 webpack-dev-server 的 proxy)解决跨域问题。
  • 虚拟代理:例如图片懒加载,先显示占位图,等图片加载完成后再替换为真实图片。
  • 缓存代理:对于开销较大的计算结果或网络请求结果进行缓存,下次请求时直接返回缓存结果。

代理模式-JS实现

// 真实图片加载类
class RealImage {
  constructor(fileName) {
    this.fileName = fileName;
    this.loadFromDisk(fileName);
  }

  loadFromDisk(fileName) {
    console.log("正在从磁盘加载 " + fileName);
  }

  display() {
    console.log("显示 " + this.fileName);
  }
}

// 代理图片类
class ProxyImage {
  constructor(fileName) {
    this.fileName = fileName;
  }

  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.fileName);
    }
    this.realImage.display();
  }
}

// 客户端调用
const image = new ProxyImage("test.jpg");

// 第一次调用,加载图片
image.display();
// 输出:
// 正在从磁盘加载 test.jpg
// 显示 test.jpg

// 第二次调用,直接显示
image.display();
// 输出:
// 显示 test.jpg

外观模式(Facade)

外观模式(Facade Pattern)提供一个统一的接口,用来访问子系统中的一群接口。外观模式定义了一个高层接口,让子系统更容易使用。

前端中的外观模式场景

  • 浏览器兼容性封装:封装不同浏览器的 API 差异,提供统一的接口。例如,封装事件监听函数,统一处理 addEventListenerattachEvent
  • 简化复杂库的使用:例如 jQueryAxios,它们为复杂的原生 DOM 操作或 XMLHttpRequest 提供了简单易用的接口。

外观模式-JS实现

// 子系统1:灯光
class Light {
  on() {
    console.log("开灯");
  }
  off() {
    console.log("关灯");
  }
}

// 子系统2:电视
class TV {
  on() {
    console.log("打开电视");
  }
  off() {
    console.log("关闭电视");
  }
}

// 子系统3:音响
class SoundSystem {
  on() {
    console.log("打开音响");
  }
  off() {
    console.log("关闭音响");
  }
}

// 外观类:家庭影院
class HomeTheaterFacade {
  constructor(light, tv, sound) {
    this.light = light;
    this.tv = tv;
    this.sound = sound;
  }

  watchMovie() {
    console.log("--- 准备看电影 ---");
    this.light.off();
    this.tv.on();
    this.sound.on();
  }

  endMovie() {
    console.log("--- 结束看电影 ---");
    this.light.on();
    this.tv.off();
    this.sound.off();
  }
}

// 客户端调用
const light = new Light();
const tv = new TV();
const sound = new SoundSystem();
const homeTheater = new HomeTheaterFacade(light, tv, sound);

homeTheater.watchMovie();
// 输出:
// --- 准备看电影 ---
// 关灯
// 打开电视
// 打开音响

homeTheater.endMovie();
// 输出:
// --- 结束看电影 ---
// 开灯
// 关闭电视
// 关闭音响

桥接模式(Bridge)

桥接模式(Bridge Pattern)将抽象部分与它的实现部分分离,使它们可以独立地变化。

前端中的桥接模式场景

  • UI组件与渲染引擎分离:例如,一个通用的图表库,可以将图表的逻辑(抽象部分)与具体的渲染方式(实现部分,如 Canvas、SVG、WebGL)分离。
  • 事件监听:在绑定事件时,将回调函数(实现部分)与事件绑定(抽象部分)分离,使得回调函数可以复用。

桥接模式-JS实现

// 实现部分接口:颜色
class Color {
  fill() {
    throw new Error("抽象方法不能调用");
  }
}

class Red extends Color {
  fill() {
    return "红色";
  }
}

class Green extends Color {
  fill() {
    return "绿色";
  }
}

// 抽象部分:形状
class Shape {
  constructor(color) {
    this.color = color;
  }

  draw() {
    throw new Error("抽象方法不能调用");
  }
}

class Circle extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的圆形`);
  }
}

class Square extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的正方形`);
  }
}

// 客户端调用
const redCircle = new Circle(new Red());
redCircle.draw(); // 画一个红色的圆形

const greenSquare = new Square(new Green());
greenSquare.draw(); // 画一个绿色的正方形

组合模式(Composite)

组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

前端中的组合模式场景

  • DOM树:DOM 树本身就是一个典型的组合模式结构,既包含具体的节点(如 divspan),也包含包含其他节点的容器。
  • 虚拟DOMVirtual DOM 也是树形结构,组件可以包含其他组件或原生元素。
  • 文件目录系统:文件夹可以包含文件或子文件夹。
  • 级联菜单:多级菜单的展示和操作。

组合模式-JS实现

// 组件基类
class Component {
  constructor(name) {
    this.name = name;
  }

  add(component) {
    throw new Error("不支持该操作");
  }

  remove(component) {
    throw new Error("不支持该操作");
  }

  print(indent = "") {
    throw new Error("不支持该操作");
  }
}

// 叶子节点:文件
class File extends Component {
  print(indent = "") {
    console.log(`${indent}- ${this.name}`);
  }
}

// 组合节点:文件夹
class Folder extends Component {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(component) {
    this.children.push(component);
  }

  remove(component) {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }

  print(indent = "") {
    console.log(`${indent}+ ${this.name}`);
    this.children.forEach(child => {
      child.print(indent + "  ");
    });
  }
}

// 客户端调用
const root = new Folder("根目录");
const folder1 = new Folder("文档");
const folder2 = new Folder("图片");

const file1 = new File("简历.doc");
const file2 = new File("照片.jpg");
const file3 = new File("logo.png");

root.add(folder1);
root.add(folder2);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

root.print();
// 输出:
// + 根目录
//   + 文档
//     - 简历.doc
//   + 图片
//     - 照片.jpg
//     - logo.png

享元模式(Flyweight)

享元模式(Flyweight Pattern)通过共享来高效地支持大量细粒度的对象。

前端中的享元模式场景

  • 事件委托:在父元素上绑定事件监听器,通过事件冒泡处理子元素的事件,避免为每个子元素绑定监听器,节省内存。
  • 对象池:在游戏开发或复杂动画中,预先创建一组对象放入池中,使用时取出,用完归还,避免频繁创建和销毁对象。
  • DOM复用:在长列表滚动(虚拟滚动)中,只渲染可视区域的 DOM 节点,回收并复用移出可视区域的节点。

享元模式-JS实现

// 享元工厂
class ShapeFactory {
  constructor() {
    this.circleMap = {};
  }

  getCircle(color) {
    if (!this.circleMap[color]) {
      this.circleMap[color] = new Circle(color);
      console.log(`创建新的 ${color} 圆形`);
    }
    return this.circleMap[color];
  }
}

// 具体享元类
class Circle {
  constructor(color) {
    this.color = color;
  }

  draw(x, y) {
    console.log(`在 (${x}, ${y}) 画一个 ${this.color} 的圆形`);
  }
}

// 客户端调用
const factory = new ShapeFactory();

const redCircle1 = factory.getCircle("红色");
redCircle1.draw(10, 10);

const redCircle2 = factory.getCircle("红色");
redCircle2.draw(20, 20);

const blueCircle = factory.getCircle("蓝色");
blueCircle.draw(30, 30);

console.log(redCircle1 === redCircle2); // true
// 输出:
// 创建新的 红色 圆形
// 在 (10, 10) 画一个 红色 的圆形
// 在 (20, 20) 画一个 红色 的圆形
// 创建新的 蓝色 圆形
// 在 (30, 30) 画一个 蓝色 的圆形
// true

项目地址

Flink ClickHouse Sink:生产级高可用写入方案|得物技术

作者 得物技术
2026年2月10日 11:11

一、背景与痛点

业务场景

在实时大数据处理场景中,Flink + ClickHouse 的组合被广泛应用于:

  • 日志处理: 海量应用日志实时写入分析库。
  • 监控分析: 业务指标、APM 数据的实时聚合。

这些场景的共同特点:

  • 数据量大:百万级 TPS,峰值可达千万级。
  • 写入延迟敏感: 需要秒级可见。
  • 数据准确性要求高:不允许数据丢失。
  • 多表写入: 不同数据根据分表策略写入不同的表。

开源 Flink ClickHouse Sink 的痛点

Flink 官方提供的 ClickHouse Sink(flink-connector-jdbc)在生产环境中存在以下严重问题:

痛点一:缺乏基于数据量的攒批机制

问题表现:

// Flink 官方 JDBC Sink 的实现
public class JdbcSink<Textends RichSinkFunction<T> {
    private final int batchSize;  // 固定批次大小
    @Override
    public void invoke(value, Context context) {
        bufferedValues.add(value);
        if (bufferedValues.size() >= batchSize) {
            // 只能基于记录数攒批,无法基于数据量
            flush();
        }
    }

带来的问题:

  1. 内存占用不可控: 100 条 1KB 的日志和 100 条 10MB 的日志占用内存差距 100 倍。
  2. OOM 风险高: 大日志记录(如堆栈转储)会迅速撑爆内存。
  3. 写入性能差: 无法根据记录大小动态调整批次,导致小记录批次过大浪费网络开销。

痛点二:无法支持动态表结构

问题表现:

// Flink 官方 Sink 只能写入固定表
public class JdbcSink {
    private final String sql;  // 固定的 INSERT SQL
    public JdbcSink(String jdbcUrl, String sql, ...) {
        this.sql = sql;  // 硬编码的表结构
    }
}

带来的问题:

  1. 多应用无法隔离: 所有应用的数据写入同一张表,通过特定分表策略区分。
  2. 扩展性差: 新增应用需要手动建表,无法动态路由。
  3. 性能瓶颈: 单表数据量过大(百亿级),查询和写入性能急剧下降。

痛点三:分布式表写入性能问题

问题表现:

// 大多数生产实现直接写入分布式表
INSERT INTO distributed_table_all VALUES (...)

ClickHouse 分布式表的工作原理:

带来的问题:

  1. 网络开销大: 数据需要经过分布式表层转发,延迟增加。
  2. 写入性能差: 分布式表增加了路由和转发逻辑,吞吐量降低。
  3. 热点问题: 所有数据先到分布式表节点,再转发,造成单点瓶颈。

生产级方案的核心改进

针对以上痛点,本方案提供了以下核心改进:

改进一:基于数据量的攒批机制

public class ClickHouseSinkCounter {
    private Long metaSize;  // 累计数据量(字节)
    public void add(LogModel value) {
        this.values.add(value);
        this.metaSize += value.getMetaSize();  // 累加数据量
    }
}
// 触发条件
private boolean flushCondition(String application) {
    return checkMetaSize(application)  // metaSize >= 10000 字节
        || checkTime(application);     // 或超时 30 秒
}

优势:

  • 内存可控: 根据数据量而非记录数攒批。
  • 精确控制: 1KB 的记录攒 10000 条 = 10MB,1MB 的记录攒 10 条 = 10MB。
  • 避免OOM: 大日志记录不会撑爆内存。

改进二:动态表结构与分片策略

public abstract class ClickHouseShardStrategy<T> {
    public abstract String getTableName(T data);
}
//日志侧实现为应用级分表
public class LogClickHouseShardStrategy extends ClickHouseShardStrategy<String> {
    @Override
    public String getTableName(String application) {
        // 动态路由:order-service → tb_logs_order_service
        return String.format("tb_logs_%s", application);
    }
}

优势:

  • 应用隔离: 日志侧内置应用级分表,每个应用独立分表。
  • 动态路由: 根据 application 自动路由到目标表。
  • 扩展性强: 新增应用无需手动建表(配合 ClickHouse 自动建表)。

改进三:本地表写入 + 动态节点发现

public class ClickHouseLocalWriter extends ClickHouseWriter {
    // 直接写本地表,避免分布式表转发
    private final ConcurrentMap<String, HikariDataSource> dataSourceMap;
    @Override
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 1. 动态获取集群节点列表
        List<String> healthyHosts = getHealthyHosts(exceptionHosts);
        // 2. 随机选择健康节点
        return dataSourceMap.get(healthyHosts.get(random.nextInt(size)));
    }
}

优势:

  • 性能提升: 直接写本地表,避免网络转发。
  • 高可用: 动态节点发现 + 故障节点剔除。
  • 负载均衡: 随机选择 + Shuffle 初始化。

技术方案概览

基于以上改进,本方案提供了以下核心能力:

  1. 本地表/分布式表写入: 性能优化与高可用平衡。
  2. 分片策略: 按应用维度路由与隔离。
  3. 攒批与内存控制: 双触发机制(数据量 + 超时)。
  4. 流量控制与限流: 有界队列 + 连接池。
  5. 健壮的重试机制: 递归重试 + 故障节点剔除。
  6. Checkpoint 语义保证: At-Least-Once 数据一致性。

二、核心架构设计

架构图

核心组件

核心流程

三、本地表 vs 分布式表写入

ClickHouse 表结构说明

ClickHouse 推荐直接写本地表,原因:

  1. 写入性能: 避免分布式表的网络分发。
  2. 数据一致性: 直接写入目标节点,减少中间环节故障点,比分布式表写入更安全,利于工程化。
  3. 负载均衡: 客户端路由实现负载分散。
-- 本地表(实际存储数据)
CREATE TABLE tb_logs_local ON CLUSTER 'default' (
    application String,
    environment String,
    message String,
    log_time DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(log_time)
ORDER BY (application, log_time);
-- 分布式表(逻辑视图,不存储数据)
CREATE TABLE tb_logs_all ON CLUSTER 'default' AS tb_logs_local
ENGINE = Distributed('default', dw_log, tb_logs_local, cityHash64(application));

HikariCP 连接池配置

// HikariCP 连接池配置
public class ClickHouseDataSourceUtils {
    private static HikariConfig getHikariConfig(DataSourceImpl dataSource) {
        HikariConfig config = new HikariConfig();
        config.setConnectionTimeout(30000L);    // 连接超时 30s
        config.setMaximumPoolSize(20);          // 最大连接数 20
        config.setMinimumIdle(2);               // 最小空闲 2
        config.setDataSource(dataSource);
        return config;
    }
    private static Properties getClickHouseProperties(ClickHouseSinkCommonParams params) {
        Properties props = new Properties();
        props.setProperty("user", params.getUser());
        props.setProperty("password", params.getPassword());
        props.setProperty("database", params.getDatabase());
        props.setProperty("socket_timeout""180000");      // Socket 超时 3 分钟
        props.setProperty("socket_keepalive""true");      // 保持连接
        props.setProperty("http_connection_provider""APACHE_HTTP_CLIENT");
        return props;
    }
}

配置说明:

  • maxPoolSize=20:每个 ClickHouse 节点最多 20 个连接。
  • minIdle=2:保持 2 个空闲连接,避免频繁创建。
  • socket_timeout=180s:Socket 超时 3 分钟,防止长时间查询阻塞。

ClickHouseLocalWriter:动态节点发现

public class ClickHouseLocalWriter extends ClickHouseWriter {
    // 本地节点缓存,按 IP 维护
    private final ConcurrentMap<StringHikariDataSource> dataSourceMap;
    // 动态获取集群本地表节点
    private final ClusterIpsUtils clusterIpsUtils;
    // IP 变更标志(CAS 锁,避免并发更新)
    private static final AtomicBoolean IP_CHANGING = new AtomicBoolean(false);
    @Override
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 1️⃣ 检测集群节点变化(通过 CAS 避免并发更新)
        if (clusterIpsChanged() && IP_CHANGING.compareAndSet(falsetrue)) {
            try {
                ipChanged(); // 动态更新 dataSourceMap
            } finally {
                IP_CHANGING.set(false);
            }
        }
        // 2️⃣ 获取异常节点列表(从 Redis + APM 实时查询)
        Set<String> exceptIps = clusterIpsUtils.getExceptIps();
        exceptIps.addAll(exceptionHosts);
        // 3️⃣ 过滤健康节点,随机选择
        List<String> healthyHosts = dataSourceMap.keySet().stream()
            .filter(host -> !exceptIps.contains(host))
            .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(healthyHosts)) {
            throw new RuntimeException("Can't get datasource from local cache");
        }
        return dataSourceMap.get(healthyHosts.get(random.nextInt(healthyHosts.size())));
    }
    private void ipChanged() {
        List<String> clusterIps = clusterIpsUtils.getClusterIps();
        // 新增节点:自动创建连接池
        clusterIps.forEach(ip ->
            dataSourceMap.computeIfAbsent(ip, v ->
                createHikariDataSource(ip, port)
            )
        );
        // 移除下线节点:关闭连接池
        dataSourceMap.forEach((ip, ds) -> {
            if (!clusterIps.contains(ip)) {
                dataSourceMap.remove(ip);
                ds.close();
            }
        });
    }
}

核心逻辑:

  1. 动态节点发现: 从 system.clusters 查询所有节点。
  2. 自动扩缩容: 节点上线自动加入,下线自动剔除。
  3. 故障节点剔除: 通过 APM 监控,自动剔除异常节点。
  4. 负载均衡: 随机选择健康节点,避免热点。

集群节点动态发现(ClusterIpsUtils)

public class ClusterIpsUtils {
    // 从 system.clusters 查询所有节点
    private static final String QUERY_CLUSTER_IPS =
        "select host_address from system.clusters where cluster = 'default'";
    // LoadingCache:定时刷新节点列表(1 小时)
    private final LoadingCache<StringList<String>> clusterIpsCache =
        CacheBuilder.newBuilder()
            .expireAfterAccess(10, TimeUnit.HOURS)
            .refreshAfterWrite(1, TimeUnit.HOURS)
            .build(CacheLoader.asyncReloading(new CacheLoader<>() {
                @Override
                public List<String> load(String dbName) {
                    return queryClusterIps();  // 定时刷新节点列表
                }
            }));
    // 异常节点缓存(1 分钟刷新)
    private final LoadingCache<StringFlinkExceptIpModel> exceptIpsCache =
        CacheBuilder.newBuilder()
            .refreshAfterWrite(1, TimeUnit.MINUTES)
            .build(CacheLoader.asyncReloading(new CacheLoader<>() {
                @Override
                public FlinkExceptIpModel load(String dbName) {
                    return queryExceptIp();  // 从 Redis + APM 查询异常节点
                }
            }));
}

异常节点监控策略:

  • 磁盘使用率 >= 90%: 从 APM 查询 Prometheus 指标,自动加入黑名单。
  • HTTP 连接数 >= 50: 连接数过多说明节点压力大,自动加入黑名单。
  • 人工配置: 通过 Redis 配置手动剔除节点

数据来源:

  1. ClickHouse system.clusters 表: 获取所有集群节点。
  2. APM Prometheus 接口: 监控节点健康状态。
  3. Redis 缓存: 人工配置的异常节点。

负载均衡优化

public class ClickHouseWriter {
    public <T> ClickHouseWriter(...) {
        // Shuffle:随机打乱数据源顺序
        Collections.shuffle(clickHouseDataSources);
        this.clickHouseDataSources = clickHouseDataSources;
    }
    public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
        // 轮询 + 随机选择(已 shuffle,避免热点)
        int current = this.currentRandom.getAndIncrement();
        if (current >= clickHouseDataSources.size()) {
            this.currentRandom.set(0);
        }
        return clickHouseDataSources.get(currentRandom.get());
    }
}

优势:

  • 初始化时 shuffle,避免所有 writer 同时从第一个节点开始。
  • 轮询 + 随机选择,负载分散更均匀。
  • 故障节点自动剔除。

四、支持分表策略

分片策略抽象

public abstract class ClickHouseShardStrategy<T> {
    private String tableName;      // 表名模板,如 "tb_log_%s"
    private Integer tableCount;    // 分表数量
    // 根据数据决定目标表名
    public abstract String getTableName(T data);
}

日志分片实现

public class LogClickHouseShardStrategy extends ClickHouseShardStrategy<String> {
    @Override
    public String getTableName(String application) {
        // 表名格式:tb_log_{application}
        // 例如:application = "order-service" -> table = "tb_log_order_service"
        return String.format(
            this.getTableName(),
            application.replace("-""_").toLowerCase()
        );
    }
}

按表(应用)维度的缓冲区

日志侧维度降级为应用名称维度缓冲区,实则因为按照应用分表,

业务方可使用自身分表策略携带表名元数据,进行表维度缓冲。

public class ClickHouseShardSinkBuffer {
    // 按 application 分组的缓冲区(ConcurrentHashMap 保证并发安全)
    private final ConcurrentHashMap<StringClickHouseSinkCounter> localValues;
    public void put(LogModel value) {
        String application = value.getApplication();
        // 1️⃣ 检查是否需要 flush
        if (flushCondition(application)) {
            addToQueue(application); // 触发写入
        }
        // 2️⃣ 添加到缓冲区(线程安全的 compute 操作)
        localValues.compute(application, (k, v) -> {
            if (v == null) v = new ClickHouseSinkCounter();
            v.add(value);
            return v;
        });
    }
    private void addToQueue(String application) {
        localValues.computeIfPresent(application, (k, v) -> {
            // 深拷贝并清空(避免并发修改异常)
            List<LogModel> deepCopy = v.copyValuesAndClear();
            // 构造请求 Blank:application + targetTable + values
            String targetTable = shardStrategy.getTableName(application);
            ClickHouseRequestBlank blank = new ClickHouseRequestBlank(deepCopy, application, targetTable);
            // 放入队列
            writer.put(blank);
            return v;
        });
    }
}

核心设计:

  • 应用隔离: 每个表(应用)独立的 buffer,互不影响。
  • 线程安全: 使用 ConcurrentHashMap.compute()保证并发安全。
  • 深拷贝: List.copyOf() 创建不可变副本,避免并发修改。
  • 批量清空: 一次性取出所有数据,清空计数器。

五、攒批与内存控制

双触发机制

public class ClickHouseShardSinkBuffer {
    private final int maxFlushBufferSize;  // 最大批次大小(如 10000)
    private final long timeoutMillis;      // 超时时间(如 30s)
    // 触发条件检查(满足任一即触发)
    private boolean flushCondition(String application) {
        return localValues.get(application) != null
            && (checkMetaSize(application) || checkTime(application));
    }
    // 条件1:达到批次大小
    private boolean checkMetaSize(String application) {
        return localValues.get(application).getMetaSize() >= maxFlushBufferSize;
    }
    // 条件2:超时
    private boolean checkTime(String application) {
        long current = System.currentTimeMillis();
        return current - localValues.get(application).getInsertTime() > timeoutMillis;
    }
}

批次大小计算

public class ClickHouseSinkCounter {
    private final List<LogModel> values;
    private Long metaSize; // 累计的 metaSize(字节)
    public void add(LogModel value) {
        this.values.add(value);
        this.metaSize += value.getMetaSize(); // 累加 metaSize
    }
    public List<LogModel> copyValuesAndClear() {
        List<LogModel> logModels = List.copyOf(this.values); // 深拷贝(不可变)
        this.values.clear();
        this.metaSize = 0L;
        this.insertTime = System.currentTimeMillis();
        return logModels;
    }
}

关键点:

  • 使用 metaSize(字节数)而非记录数控制批次,内存控制更精确。
  • List.copyOf() 创建不可变副本,避免并发修改。
  • 清空后重置 insertTime,保证超时触发准确性。

带随机抖动的超时

private final long timeoutMillis;
public ClickHouseShardSinkBuffer(..., int timeoutSec, ...) {
    // 基础超时 + 10% 随机抖动(避免惊群效应)
    this.timeoutMillis = TimeUnit.SECONDS.toMillis(timeoutSec)
                      + new SecureRandom().nextInt((int) (timeoutSec * 0.1 * 1000));
}

目的: 避免多个TM 同时触发 flush,造成写入流量峰值。

配置示例

ClickHouseShardSinkBuffer.Builder
    .aClickHouseSinkBuffer()
    .withTargetTable("single_table")  //单表时,可直接使用指定表名
    .withMaxFlushBufferSize(10000)  // 对应字节数
    .withTimeoutSec(30)              // 30 秒超时
    .withClickHouseShardStrategy(new LogClickHouseShardStrategy("table_prefix_%s", 8))  //分表策略时,使用
    // 分表策略可根据业务实际情况进行扩展
    .build(clickHouseWriter);

六、写入限流与流量控制

有界队列设计

public class ClickHouseWriter {
    // 有界阻塞队列
    private final BlockingQueue<ClickHouseRequestBlank> commonQueue;
    public ClickHouseWriter(ClickHouseSinkCommonParams sinkParams, ...) {
        // 队列最大容量配置(默认 10)
        this.commonQueue = new LinkedBlockingQueue<>(sinkParams.getQueueMaxCapacity());
    }
    public void put(ClickHouseRequestBlank params) {
        unProcessedCounter.incrementAndGet();
        // put() 方法在队列满时会阻塞,实现背压
        commonQueue.put(params);
    }
}

背压传导:

线程池并发控制

public class ClickHouseWriter {
    private final int numWriters; // 写入线程数
    private ExecutorService service;
    private void buildComponents() {
        ThreadFactory threadFactory = ThreadUtil.threadFactory("clickhouse-writer");
        service = Executors.newFixedThreadPool(numWriters, threadFactory);
        // 创建多个 WriterTask 并提交
        for (int i = 0; i < numWriters; i++) {
            WriterTask task = new WriterTask(i, commonQueue, sinkParams, futures, unProcessedCounter);
            service.submit(task);
        }
    }
}

WriterTask 消费逻辑

class WriterTask implements Runnable {
    @Override
    public void run() {
        isWorking = true;
        while (isWorking || !queue.isEmpty()) {
            // poll() 超时返回(100ms),避免无限等待
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置超时(3 分钟)
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES);
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    // final 进行未知异常兜底,防止为捕获异常造成future状态不完成,永久阻塞
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Unknown exception"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}

配置参数

七、重试机制与超时控制

Future 超时控制

public class ClickHouseWriter {
    private final int numWriters; // 写入线程数
    private ExecutorService service;
    private void buildComponents() {
        ThreadFactory threadFactory = ThreadUtil.threadFactory("clickhouse-writer");
        service = Executors.newFixedThreadPool(numWriters, threadFactory);
        // 创建多个 WriterTask 并提交
        for (int i = 0; i < numWriters; i++) {
            WriterTask task = new WriterTask(i, commonQueue, sinkParams, futures, unProcessedCounter);
            service.submit(task);
        }
    }
}

超时策略:

  • Future 超时: 3 分钟(orTimeout)。
  • Socket 超时: 3 分钟(socket_timeout=180000)。
  • 连接超时: 30 秒(connectionTimeout=30000)。

重试逻辑

class WriterTask implements Runnable {
    @Override
    public void run() {
        isWorking = true;
        while (isWorking || !queue.isEmpty()) {
            // poll() 超时返回(100ms),避免无限等待
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置超时(3 分钟)
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES);
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    // final 进行未知异常兜底,防止为捕获异常造成future状态不完成,永久阻塞
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Unknown exception"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}

重试控制逻辑

private void handleUnsuccessfulResponse(..., Set<String> exceptHosts) {
    // 检查 Future 是否已完成(避免重复完成)
    if (future.isDone()) {
        return;
    }
    if (attemptCounter >= maxRetries) {
        // 达到最大重试次数,标记失败
        future.completeExceptionally(new RuntimeException("Max retries exceeded"));
    } else {
        // 递归重试
        requestBlank.incrementCounter();
        send(requestBlank, future, exceptHosts); // 递归调用,排除失败节点
    }
}

重试策略:

  • 递归重试: 失败后递归调用,直到成功或达到最大次数。
  • 异常节点隔离: 每次重试时排除失败的节点(exceptHosts)。
  • 超时控制: Future 超时(3 分钟)防止永久阻塞。

为什么递归重试是更好的选择

递归重试(当前实现)

队列重试(假设方案)

保证一致性

  // ClickHouseWriter.java:139-158
  while (!futures.isEmpty() || unProcessedCounter.get() > 0) {
      CompletableFuture<Void> future = FutureUtil.allOf(futures);
      future.get(3, TimeUnit.MINUTES);  // 阻塞直到全部完成
  }
  • Checkpoint 时所有数据要么全部成功,要么全部失败。
  • 重启后不会有部分数据重复的问题。

简单可靠

  • 代码逻辑清晰。
  • 对于队列重试且不重复,需要复杂的二阶段提交(这里暂不展开),大幅增加代码复杂度。

性能可接受

class WriterTask implements Runnable {
    @Override
    public void run() {
        while (isWorking || !queue.isEmpty()) {
            ClickHouseRequestBlank blank = queue.poll(100, TimeUnit.MILLISECONDS);
            if (blank != null) {
                // 创建 Future 并设置 3 分钟超时
                CompletableFuture<Boolean> future = new CompletableFuture<>();
                future.orTimeout(3, TimeUnit.MINUTES); // 防止永久阻塞
                futures.add(future);
                try {
                    send(blank, future, new HashSet<>());
                } finally {
                    if (!future.isDone()) {
                        future.completeExceptionally(new RuntimeException("Timeout"));
                    }
                    queueCounter.decrementAndGet();
                }
            }
        }
    }
}
  • 虽然阻塞,但有超时保护。
  • ClickHouse 写入通常很快(秒级)。
  • 网络故障时重试也合理。

避开故障节点

  // ClickHouseWriter.java:259-260
  HikariDataSource dataSource = getNextDataSource(exceptHosts);
  • 递归时可以传递 exceptHosts。
  • 自动避开失败的节点。
  • 提高成功率。

异常节点剔除

// 特殊错误码列表(自动加入黑名单)
private final List<Integer> ignoreHostCodes = Arrays.asList(2101002);
public HikariDataSource getNextDataSource(Set<String> exceptionHosts) {
    if (CollectionUtils.isNotEmpty(exceptionHosts)) {
        // 过滤异常节点
        List<HikariDataSource> healthyHosts = clickHouseDataSources.stream()
            .filter(ds -> !exceptionHosts.contains(getHostFromUrl(ds)))
            .collect(Collectors.toList());
        if (CollectionUtils.isEmpty(healthyHosts)) {
            return null// 所有节点都异常
        }
        return healthyHosts.get(random.nextInt(healthyHosts.size()));
    }
    // 正常轮询(已 shuffle,避免热点)
    return clickHouseDataSources.get(currentRandom.getAndIncrement() % size);
}

故障节点剔除策略:

  1. 错误码 210(网络异常): 自动加入黑名单。
  2. 错误码 1002(连接池异常): 自动加入黑名单。
  3. APM 监控: 磁盘 >= 90%、HTTP 连接 >= 50 的节点。
  4. 手动配置: 通过 Redis 配置剔除。

恢复机制:

  • LoadingCache 定时刷新(1 分钟)。
  • 节点恢复健康后自动从黑名单移除。

重试流程图

八、异常处理模式

两种 Sink 模式

public Sink buildSink(String targetTable, String targetCount, int maxBufferSize) {
    IClickHouseSinkBuffer buffer = ClickHouseShardSinkBuffer.Builder
        .aClickHouseSinkBuffer()
        .withTargetTable(targetTable)
        .withMaxFlushBufferSize(maxBufferSize)
        .withClickHouseShardStrategy(new LogClickHouseShardStrategy(targetTable, count))
        .build(clickHouseWriter);
    // 根据配置选择模式
    if (ignoringClickHouseSendingExceptionEnabled) {
        return new UnexceptionableSink(buffer);  // 忽略异常
    } else {
        return new ExceptionsThrowableSink(buffer); // 抛出异常
    }
}

UnexceptionableSink(忽略异常 - At-Most-Once)

public class UnexceptionableSink implements Sink<LogModel> {
    private final IClickHouseSinkBuffer<LogModel> buffer;
    @Override
    public void put(LogModel message) {
        buffer.put(message);  // 不检查 Future 状态
    }
    @Override
    public void flush() {
        buffer.flush();
    }
}

适用场景:

  • 允许部分数据丢失。
  • 不希望因写入异常导致任务失败。
  • 对数据准确性要求不高(如日志统计)。

语义保证:At-Most-Once(最多一次)

ExceptionsThrowableSink(抛出异常 - At-Least-Once)

public class ExceptionsThrowableSink implements Sink<LogModel> {
    private final IClickHouseSinkBuffer<LogModel> buffer;
    @Override
    public void put(LogModel message) throws ExecutionException, InterruptedException {
        buffer.put(message);
        // 每次写入都检查 Future 状态
        buffer.assertFuturesNotFailedYet();
    }
    @Override
    public void flush() throws ExecutionException, InterruptedException {
        buffer.flush();
    }
}

Future 状态检查:

public void assertFuturesNotFailedYet() throws ExecutionException, InterruptedException {
    CompletableFuture<Void> future = FutureUtil.allOf(futures);
    // 非阻塞检查
    if (future.isCompletedExceptionally()) {
        logger.error("There is something wrong with the future. exist sink now");
        future.get(); // 抛出异常,导致 Flink 任务失败
    }
}

适用场景:

  • 数据准确性要求高。
  • 需要保证所有数据写入成功。
  • 异常时希望 Flink 任务失败并重启。

语义保证:At-Least-Once(至少一次)

Future 清理策略与并发控制

定时检查器

public class ClickHouseSinkScheduledCheckerAndCleaner {
    private final ScheduledExecutorService scheduledExecutorService;
    private final List<CompletableFuture<Boolean>> futures;
    // ⚠️ volatile 保证多线程可见性(关键并发控制点)
    private volatile boolean isFlushing = false;
    public ClickHouseSinkScheduledCheckerAndCleaner(...) {
        // 单线程定时执行器
        scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(factory);
        // 定时执行清理任务(每隔 checkTimeout 秒,默认 30 秒)
        scheduledExecutorService.scheduleWithFixedDelay(getTask(), ...);
    }
    private Runnable getTask() {
        return () -> {
            synchronized (this) {
                //  关键:检查是否正在 flush,避免并发冲突
                if (isFlushing) {
                    return// Checkpoint 期间暂停清理
                }
                // 1️⃣ 清理已完成的 Future
                futures.removeIf(filter);
                // 2️⃣ 触发所有 Buffer 的 flush(检查是否需要写入)
                clickHouseSinkBuffers.forEach(IClickHouseSinkBuffer::tryAddToQueue);
            }
        };
    }
    // Checkpoint flush 前调用(暂停 cleaner)
    public synchronized void beforeFlush() {
        isFlushing = true;
    }
    // Checkpoint flush 后调用(恢复 cleaner)
    public synchronized void afterFlush() {
        isFlushing = false;
    }
}

核心设计:

  • volatile boolean isFlushing: 标志位,协调 cleaner 与 checkpoint 线程。
  • synchronized (this): 保证原子性,避免并发冲突。
  • 单线程执行器: 避免 cleaner 内部并发问题。

并发控制机制

问题场景:

时间轴冲突:
T1: Cleaner 线程正在执行 tryAddToQueue()
T2: Checkpoint 触发,调用 sink.flush()
T3: Cleaner 同时也在执行 tryAddToQueue()
    ├─ 可能导致:数据重复写入
    ├─ 可能导致:Buffer 清空顺序混乱
    └─ 可能导致:Future 状态不一致

解决方案:

// ClickHouseSinkManager.flush()
public void flush() {
    // 1️⃣ 暂停定时清理任务(设置标志)
    clickHouseSinkScheduledCheckerAndCleaner.beforeFlush(); // isFlushing = true
    try {
        // 2️⃣ 执行 flush(此时 cleaner 线程会跳过执行)
        clickHouseWriter.waitUntilAllFuturesDone(falsefalse);
    } finally {
        // 3️⃣ 恢复定时清理任务
        clickHouseSinkScheduledCheckerAndCleaner.afterFlush(); // isFlushing = false
    }
}

并发控制流程:

关键设计点:

  1. volatile 保证可见性: isFlushing 使用 volatile,确保多线程间的可见性。
  2. synchronized 保证原子性: getTask() 整个方法体使用 synchronized (this)。
  3. 标志位协调: 通过 isFlushing 标志实现两个线程间的协调。
  4. finally 确保恢复: 即使 waitUntilAllFuturesDone() 异常,也会在 finally 中恢复 cleaner。

避免的并发问题:

  • 数据重复写入: Cleaner 和 Checkpoint 同时 flush。
  • Buffer 状态不一致: 一边清空一边写入。
  • Future 清理冲突: 正在使用的 Future 被清理。

性能影响:

  • Checkpoint flush 期间,cleaner 暂停执行(通常 1-3 秒)。
  • Cleaner 跳过的周期会在下次正常执行时补偿。
  • 对整体吞吐影响极小(cleaner 间隔通常 30 秒)。

九、Checkpoint 语义保证

为什么 Checkpoint 时必须 Flush?

不 Flush 的后果

不Flush导致数据永久丢失

正确做法

@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
    logger.info("start doing snapshot. flush sink to ck");
    // 1. 先 flush buffer(将内存数据写入 ClickHouse)
    if (sink != null) {
        sink.flush();
    }
    // 2. 等待所有写入完成
    if (sinkManager != null && !sinkManager.isClosed()) {
        sinkManager.flush();
    }
    // 此时 Checkpoint 才能标记为成功
    logger.info("doing snapshot. flush sink to ck");
}

Flush 实现与并发协调

public class ClickHouseSinkManager {
    public void flush() {
        //  步骤1:暂停定时清理任务
        clickHouseSinkScheduledCheckerAndCleaner.beforeFlush(); // isFlushing = true
        try {
            //  步骤2:执行 buffer flush + 等待所有写入完成
            clickHouseWriter.waitUntilAllFuturesDone(falsefalse);
        } finally {
            //  步骤3:恢复定时清理任务(finally 确保执行)
            clickHouseSinkScheduledCheckerAndCleaner.afterFlush(); // isFlushing = false
        }
    }
}

并发协调详解:

// cleaner 线程执行流程
synchronized (this) {
    if (isFlushing) {
        return// Checkpoint 期间跳过本次执行
    }
    // 正常执行:清理已完成的 Future + 触发 Buffer flush
    futures.removeIf(filter);
    buffers.forEach(Buffer::tryAddToQueue);
}

关键点:

  • volatile 可见性: isFlushing 使用 volatile 确保 cleaner 线程立即看到状态变化。
  • synchronized互斥: getTask()方法体使用 synchronized (this) 确保原子性。
  • 标志位协调: 通过 beforeFlush() / afterFlush() 管理标志位。
  • finally 保证恢复: 即使 flush 异常,也会在 finally 中恢复 cleaner。

等待所有 Future 完成

public synchronized void waitUntilAllFuturesDone(boolean stopWriters, boolean clearFutures) {
    try {
        // 循环等待:直到所有 Future 完成 + 队列清空
        while (!futures.isEmpty() || unProcessedCounter.get() > 0) {
            CompletableFuture<Void> all = FutureUtil.allOf(futures);
            // 最多等待 3 分钟(与 Future 超时一致)
            all.get(3, TimeUnit.MINUTES);
            // 移除已完成的 Future(非异常)
            futures.removeIf(f -> f.isDone() && !f.isCompletedExceptionally());
            // 检查是否有异常 Future
            if (anyFutureFailed()) {
                break; // 有异常则退出
            }
        }
    } finally {
        if (stopWriters) stopWriters();
        if (clearFutures) futures.clear();
    }
}

关键逻辑:

  • 循环等待直到所有 Future 完成 + 队列清空。
  • 超时 3 分钟(与 Future 超时一致)。
  • 移除已完成的非异常 Future。
  • 有异常时退出循环。

三种 Flush 触发方式对比

Checkpoint 参数配置

// Checkpoint 配置建议
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用 Checkpoint(间隔 1 分钟)
env.enableCheckpointing(60000);
// Checkpoint 超时(必须大于 Future 超时 + 重试时间)
// 建议:CheckpointTimeout > FutureTimeout * MaxRetries
env.getCheckpointConfig().setCheckpointTimeout(600000); // 10 分钟
// 一致性模式
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 最小间隔(避免过于频繁)
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000); // 30 秒
// 最大并发 Checkpoint 数
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

语义保证

推荐配置:

生产环境: 使用 ExceptionsThrowableSink + Checkpoint。

允许部分丢失: 使用 UnexceptionableSink。

十、最佳实践与调优

生产配置

// ========== ClickHouse 连接参数 ==========
clickhouse.sink.target-table = tb_logs_local
clickhouse.sink.max-buffer-size104857600        // 批次大小
clickhouse.sink.table-count0                // 0 表示不分表
// ========== 写入性能参数 ==========
clickhouse.sink.num-writers10               // 写入线程数
clickhouse.sink.queue-max-capacity10        // 队列容量
clickhouse.sink.timeout-sec30               // flush 超时
clickhouse.sink.retries10                   // 最大重试次数
clickhouse.sink.check.timeout-sec30         // 定时检查间隔
// ========== 异常处理参数 ==========
clickhouse.sink.ignoring-clickhouse-sending-exception-enabledfalse
clickhouse.sink.local-address-enabledtrue   // 启用本地表写入
// ========== ClickHouse 集群配置 ==========
clickhouse.access.hosts192.168.1.1:8123,192.168.1.2:8123,192.168.1.3:8123
clickhouse.access.user = default
clickhouse.access.password = ***
clickhouse.access.database = dw_xx_xx
clickhouse.access.cluster = default
// ========== HikariCP 连接池配置 ==========
connectionTimeout30000                      // 连接超时 30s
maximumPoolSize20                           // 最大连接数 20
minimumIdle2                                // 最小空闲 2
socket_timeout180000                        // Socket 超时 3mi

性能调优

故障排查

十一、总结

本文深入分析了 Flink ClickHouse Sink 的实现方案,核心亮点包括:

技术亮点

  • 连接池选型: 使用 HikariCP,性能优异,连接管理可靠。
  • Future 超时控制: orTimeout(3min) 防止永久阻塞。
  • 显式资源管理: Connection 和 PreparedStatement 显式关闭,防止连接泄漏。
  • 负载均衡优化: Shuffle 初始化 + 轮询选择,避免热点。
  • 异常处理增强: future.isDone() 检查,避免重复完成。
  • 本地表写入: 动态节点发现 + 故障剔除,写入性能提升。
  • 分片策略: 按表(应用)维度路由,独立缓冲和隔离。
  • 攒批优化: 双触发机制(大小 + 超时)+ 随机抖动。
  • 流量控制: 有界队列 + 线程池,实现背压。
  • 健壮重试: 递归重试 + 异常节点剔除 + 最大重试限制。

Checkpoint 语义

  • At-Least-Once: ExceptionsThrowableSink + Checkpoint。
  • At-Most-Once: UnexceptionableSink。
  • Exactly-Once: 需要配合 ClickHouse 事务(未实现)。

生产建议

  1. 必须: Checkpoint 时 flush,否则会丢数据。
  2. 推荐: 使用 HikariCP + 本地表写入。
  3. 推荐: 配置合理的超时(Future < Socket < Checkpoint)。
  4. 推荐: 监控队列大小、Future 失败率、重试次数。

该方案已在生产环境大规模验证,能够稳定支撑百万级 TPS 的日志写入场景。

往期回顾

1.服务拆分之旅:测试过程全揭秘|得物技术

2.大模型网关:大模型时代的智能交通枢纽|得物技术

3.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

4.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

5.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

文 /虚白

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

图结构完全解析:从基础概念到遍历实现

作者 颜酱
2026年2月9日 21:58

图结构完全解析:从基础概念到遍历实现

图是计算机科学中最核心的数据结构之一,它能抽象现实世界中各类复杂的关系网络——从地图导航的路径规划,到社交网络的好友推荐,再到物流网络的成本优化,都离不开图结构的应用。本文将从图的基础概念出发,逐步讲解图的存储方式、通用实现,以及核心的遍历算法(DFS/BFS),帮助你彻底掌握图结构的核心逻辑。

一、图的核心概念

1.1 基本构成

图由节点(Vertex)边(Edge) 组成:

  • 节点:表示实体,有唯一ID标识;

  • 边:表示节点间的关系,可分为:

    • 有向/无向:有向边(如A→B)仅表示单向关系,无向边(如A-B)等价于双向有向边;

    • 加权/无权:加权边附带数值(如距离、成本),无权边可视为权重为1的特殊情况。

1.2 关键属性

(1)度
  • 无向图:节点的度 = 相连边的条数;

  • 有向图:入度(指向该节点的边数)+ 出度(该节点指向其他的边数)。

(2)稀疏图 vs 稠密图

简单图(无自环、无多重边)中,V个节点最多有 V(V1)/2V(V-1)/2 条边:

  • 稀疏图:边数E远小于 V2V^2 (如社交网络);

  • 稠密图:边数E接近 V2V^2 (如全连接网络)。

1.3 子图相关

  • 子图:节点和边均为原图的子集;

  • 生成子图:包含原图所有节点,仅保留部分边(如最小生成树);

  • 导出子图:选择部分节点,且包含这些节点在原图中的所有边。

1.4 连通性

  • 无向图:

    • 连通图:任意两节点间有路径可达;

    • 连通分量:非连通图中的最大连通子图;

  • 有向图:

    • 强连通:任意两节点间有双向有向路径;

    • 弱连通:忽略边方向后为连通图。

1.5 图与树的关系

图是多叉树的延伸:树无环、仅允许父→子指向,而图可成环、节点间可任意指向。树的遍历逻辑(DFS/BFS)完全适用于图,仅需增加「标记已访问节点」的逻辑避免环导致的死循环。

二、图的存储方式

图的存储核心是「记录节点间的连接关系」,主流方式有邻接表邻接矩阵两种,二者各有适用场景。

2.1 邻接表

核心结构

以节点ID为键,存储该节点的所有出边(包含目标节点+权重):

  • 数组版:graph[x] 存储节点x的出边列表(适用于节点ID为连续整数);

  • Map版:graph.get(x) 存储节点x的出边列表(适用于任意类型节点ID)。

特点
  • 空间复杂度: O(V+E)O(V+E) (仅存储实际存在的边,适合稀疏图);

  • 时间复杂度:增边 O(1)O(1) ,删/查边 O(E)O(E) (E为节点出边数),获取邻居 O(1)O(1)

2.2 邻接矩阵

核心结构

二维数组 matrix[from][to]

  • 无权图:true/false 表示是否有边;

  • 加权图:数值表示权重,null 表示无边(避免0权重歧义)。

特点
  • 空间复杂度: O(V2)O(V^2) (需预分配所有节点组合,适合稠密图);

  • 时间复杂度:增/删/查边/获取权重均为 O(1)O(1) ,获取邻居 O(V)O(V)

2.3 存储方式对比

特性 邻接表 邻接矩阵
空间效率 稀疏图更优 稠密图更优
增删查边 增边快、删/查边慢 所有操作均快
节点ID支持 支持任意类型(Map版) 仅支持连续整数
适用场景 大多数稀疏图场景 节点少、需快速查边

三、图的通用实现

基于邻接表/邻接矩阵,我们实现支持「增删查改」的通用加权有向图类,可灵活适配无向图(双向加边)、无权图(权重默认1)。

3.1 邻接表(数组版):适用于连续整数节点ID


/**
 * 加权有向图(邻接表-数组版)
 * 核心:节点ID为0~n-1的连续整数,二维数组存储出边
 */
class WeightedDigraphArray {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error(`节点数必须是正整数(当前传入:${n})`);
        }
        this.nodeCount = n;
        this.graph = Array.from({ length: n }, () => []); // 邻接表初始化
    }

    // 私有方法:校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        // 避免重复加边
        if (this.hasEdge(from, to)) {
            this.removeEdge(from, to);
        }
        this.graph[from].push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const originalLength = this.graph[from].length;
        this.graph[from] = this.graph[from].filter(edge => edge.to !== to);
        return this.graph[from].length < originalLength;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.graph[from].some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        const edge = this.graph[from].find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        return [...this.graph[v]]; // 返回拷贝,避免外部修改
    }

    // 打印邻接表(调试用)
    printAdjList() {
        console.log('=== 加权有向图邻接表 ===');
        for (let i = 0; i < this.nodeCount; i++) {
            const edges = this.graph[i].map(edge => `${edge.to}(${edge.weight})`).join(', ');
            console.log(`节点${i}的出边:${edges || '无'}`);
        }
    }
}

3.2 邻接表(Map版):适用于任意类型节点ID


/**
 * 加权有向图(邻接表-Map版)
 * 核心:支持动态添加节点,节点ID可为任意可哈希类型(数字/字符串等)
 */
class WeightedDigraphMap {
    constructor() {
        this.graph = new Map(); // key: 节点ID,value: 出边列表
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error('边的权重必须是有效数字');
        }
        if (!this.graph.has(from)) {
            this.graph.set(from, []);
        }
        this.removeEdge(from, to); // 去重
        this.graph.get(from).push({ to, weight });
    }

    // 删除有向边
    removeEdge(from, to) {
        if (!this.graph.has(from)) return false;
        const edges = this.graph.get(from);
        const filtered = edges.filter(edge => edge.to !== to);
        this.graph.set(from, filtered);
        return filtered.length < edges.length;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        if (!this.graph.has(from)) return false;
        return this.graph.get(from).some(edge => edge.to === to);
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        if (!this.graph.has(from)) {
            throw new Error(`不存在节点${from}`);
        }
        const edge = this.graph.get(from).find(edge => edge.to === to);
        if (!edge) throw new Error(`不存在边${from}${to}`);
        return edge.weight;
    }

    // 获取节点所有出边
    getNeighbors(v) {
        return this.graph.get(v) || [];
    }

    // 获取所有节点
    getNodes() {
        return Array.from(this.graph.keys());
    }
}

3.3 邻接矩阵版:适用于节点数少的场景


/**
 * 加权有向图(邻接矩阵版)
 * 核心:二维数组存储边权重,null表示无边
 */
class WeightedDigraphMatrix {
    constructor(n) {
        if (!Number.isInteger(n) || n <= 0) {
            throw new Error('节点数必须是正整数');
        }
        this.nodeCount = n;
        this.matrix = Array.from({ length: n }, () => Array(n).fill(null));
    }

    // 校验节点合法性
    _validateNode(node) {
        if (!Number.isInteger(node) || node < 0 || node >= this.nodeCount) {
            throw new Error(`节点${node}非法!合法范围:0 ~ ${this.nodeCount - 1}`);
        }
    }

    // 添加加权有向边
    addEdge(from, to, weight) {
        this._validateNode(from);
        this._validateNode(to);
        if (typeof weight !== 'number' || isNaN(weight)) {
            throw new Error(`边${from}${to}的权重必须是有效数字`);
        }
        this.matrix[from][to] = weight;
    }

    // 删除有向边
    removeEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        if (this.matrix[from][to] === null) return false;
        this.matrix[from][to] = null;
        return true;
    }

    // 判断边是否存在
    hasEdge(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to] !== null;
    }

    // 获取边权重
    getEdgeWeight(from, to) {
        this._validateNode(from);
        this._validateNode(to);
        return this.matrix[from][to];
    }

    // 获取节点所有出边
    getNeighbors(v) {
        this._validateNode(v);
        const neighbors = [];
        for (let to = 0; to < this.nodeCount; to++) {
            const weight = this.matrix[v][to];
            if (weight !== null) {
                neighbors.push({ to, weight });
            }
        }
        return neighbors;
    }

    // 打印邻接矩阵(调试用)
    printMatrix() {
        console.log('=== 邻接矩阵 ===');
        process.stdout.write('    ');
        for (let i = 0; i < this.nodeCount; i++) process.stdout.write(`${i}   `);
        console.log();
        for (let from = 0; from < this.nodeCount; from++) {
            process.stdout.write(`${from} | `);
            for (let to = 0; to < this.nodeCount; to++) {
                const val = this.matrix[from][to] === null ? '∅' : this.matrix[from][to];
                process.stdout.write(`${val}   `);
            }
            console.log();
        }
    }
}

3.4 适配无向图/无权图

  • 无向图:添加/删除边时,同时操作 from→toto→from

  • 无权图:复用加权图类,addEdge 时权重默认传1。

四、图的核心遍历算法

图的遍历是所有图论算法的基础,核心为深度优先搜索(DFS)广度优先搜索(BFS),二者的核心区别是「遍历顺序」:DFS先探到底再回溯,BFS逐层扩散。

4.1 深度优先搜索(DFS)

核心思想

从起点出发,沿着一条路径走到头,再回溯探索其他分支,需通过 visited/onPath 数组避免环导致的死循环。

场景1:遍历所有节点(visited数组)

visited 标记已访问的节点,确保每个节点仅遍历一次:


/**
 * DFS遍历所有节点
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 * @param {boolean[]} visited - 访问标记数组
 */
function dfsTraverseNodes(graph, s, visited) {
    if (s < 0 || s >= graph.nodeCount || visited[s]) return;
    // 前序位置:标记并访问节点
    visited[s] = true;
    console.log(`访问节点 ${s}`);
    // 递归遍历所有邻居
    for (const edge of graph.getNeighbors(s)) {
        dfsTraverseNodes(graph, edge.to, visited);
    }
    // 后序位置:可处理节点相关逻辑(如统计、计算)
}

// 调用示例
const graph = new WeightedDigraphArray(3);
graph.addEdge(0, 1, 1);
graph.addEdge(0, 2, 2);
graph.addEdge(1, 2, 3);
dfsTraverseNodes(graph, 0, new Array(graph.nodeCount).fill(false));
场景2:遍历所有路径(onPath数组)

onPath 标记当前路径上的节点,后序位置撤销标记(回溯),用于寻找所有路径:


/**
 * DFS遍历所有路径(从src到dest)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} src - 起点
 * @param {number} dest - 终点
 * @param {boolean[]} onPath - 当前路径标记
 * @param {number[]} path - 当前路径
 * @param {number[][]} res - 所有路径结果
 */
function dfsTraversePaths(graph, src, dest, onPath, path, res) {
    if (src < 0 || src >= graph.nodeCount || onPath[src]) return;
    // 前序位置:加入当前路径
    onPath[src] = true;
    path.push(src);
    // 到达终点:记录路径
    if (src === dest) {
        res.push([...path]); // 拷贝路径,避免后续修改
        path.pop();
        onPath[src] = false;
        return;
    }
    // 递归遍历邻居
    for (const edge of graph.getNeighbors(src)) {
        dfsTraversePaths(graph, edge.to, dest, onPath, path, res);
    }
    // 后序位置:回溯(移出当前路径)
    path.pop();
    onPath[src] = false;
}

// 调用示例
const res = [];
dfsTraversePaths(graph, 0, 2, new Array(graph.nodeCount).fill(false), [], res);
console.log('所有从0到2的路径:', res); // [[0,1,2], [0,2]]
场景3:有向无环图(DAG)遍历

若图无环,可省略 visited/onPath,直接遍历(如寻找所有从0到终点的路径):


var allPathsSourceTarget = function(graph) {
    const res = [];
    const path = [];
    const traverse = (s) => {
        path.push(s);
        if (s === graph.length - 1) {
            res.push([...path]);
            path.pop();
            return;
        }
        for (const v of graph[s]) traverse(v);
        path.pop();
    };
    traverse(0);
    return res;
};

4.2 广度优先搜索(BFS)

核心思想

从起点出发,逐层遍历所有节点,天然适合寻找「最短路径」(第一次到达终点的路径即为最短)。

基础版:记录遍历步数

/**
 * BFS遍历(记录步数)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverse(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [s];
    visited[s] = true;
    let step = -1; // 初始-1,进入循环后++为0(起点步数)

    while (q.length > 0) {
        step++;
        const sz = q.length;
        // 遍历当前层所有节点
        for (let i = 0; i < sz; i++) {
            const cur = q.shift();
            console.log(`访问节点 ${cur},步数 ${step}`);
            // 加入所有未访问的邻居
            for (const edge of graph.getNeighbors(cur)) {
                if (!visited[edge.to]) {
                    q.push(edge.to);
                    visited[edge.to] = true;
                }
            }
        }
    }
}
进阶版:State类适配复杂场景

通过State类封装节点和步数,适配不同权重、不同遍历目标的场景:


// 封装节点状态
class State {
    constructor(node, step) {
        this.node = node;
        this.step = step;
    }
}

/**
 * BFS遍历(State版)
 * @param {WeightedDigraphArray} graph - 图实例
 * @param {number} s - 起点
 */
function bfsTraverseState(graph, s) {
    const nodeCount = graph.nodeCount;
    const visited = new Array(nodeCount).fill(false);
    const q = [new State(s, 0)];
    visited[s] = true;

    while (q.length > 0) {
        const state = q.shift();
        const cur = state.node;
        const step = state.step;
        console.log(`访问节点 ${cur},步数 ${step}`);

        for (const edge of graph.getNeighbors(cur)) {
            if (!visited[edge.to]) {
                q.push(new State(edge.to, step + 1));
                visited[edge.to] = true;
            }
        }
    }
}

4.3 遍历算法总结

算法 核心数据结构 核心标记 适用场景 时间复杂度
DFS 递归栈 visited(遍历节点)/onPath(遍历路径) 遍历所有节点、所有路径 O(V+E)O(V+E)
BFS 队列 visited 寻找最短路径、逐层遍历 O(V+E)O(V+E)

五、总结

图结构的核心是「节点+边」的关系抽象,掌握以下关键点即可应对绝大多数场景:

  1. 存储选择:稀疏图用邻接表(省空间),稠密图用邻接矩阵(查边快);

  2. 遍历逻辑:DFS适合遍历所有路径,BFS适合找最短路径,均需标记已访问节点避免环;

  3. 扩展适配:无向图=双向有向图,无权图=权重为1的加权图,可复用通用图类;

  4. 核心思想:图是树的延伸,遍历的本质是「穷举+剪枝」(标记已访问避免死循环)。

从基础的遍历到进阶的最短路径(Dijkstra)、最小生成树(Kruskal/Prim)、拓扑排序,图论算法的核心都是「基于遍历的优化」,掌握本文的基础内容,后续学习进阶算法会事半功倍。

开源 Claude Code + Codex + 面板 的未来vibecoding平台

作者 朱昆鹏
2026年2月9日 13:38

一句话介绍

CodeMoss =

  • 多AI联动:Claude Code + Codex + Gemini + OpenCode + ......
  • 多端使用:客户端 + Jetbrains + Vscode + 移动端
  • 多周边集成:AI面板 + AI记忆 + Superpowers + OpenSpec + Spec-kit + ...

说了这么多功能,直接放实机图更容易理解

image.png

image.png


总之一句话:CodeMoss 目标打造 下一代的vibecoding 入口

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…


详细介绍

对话过程页面

image.png

侧边栏GIT模块

image.png

侧边栏文件管理模块

真的可以编辑哦~

image.png

面板模式

这不是普通的面板哦~,是真的可以并行执行任务,有完整交互的AI面板哦~

image.png

image.png

侧边栏展示

支持claude code + codex 多cli数据共同展示

image.png

终端展示

image.png

支持多平台

支持Mac 和 window 多平台


下载安装体验

功能太多了,就不赘述了,大家可以下载之后自行探索

下载地址(纯开源,无商业,放心食用):www.codemoss.ai/download


未来迭代

目前虽然能用,但是细节打磨的还不满意,我至少会每天迭代一个版本,先迭代100个版本,欢迎大家使用提出问题

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…

再次声明:本项目完全开源,0商业,使用过程全程无广,请放心食用

构建全栈AI应用:集成Ollama开源大模型

2026年2月8日 20:59

在AI技术迅猛发展的今天,开源大模型如 DeepSeek 系列为开发者提供了强大工具,而无需依赖云服务。构建一个全栈AI应用,不仅能深化对前后端分离架构的理解,还能探索AI集成的最佳实践。本文将基于一个实际项目,分享如何使用React前端、Node.js后端,并通过 LangChain 库调用 Ollama 部署的 DeepSeek-R1:8b 模型,实现一个简单的聊天功能。这个项目适用于初学者上手全栈开发,或资深开发者扩展AI能力。

项目需求源于日常场景:开发者常常需要快速测试AI响应,或构建原型应用来验证想法。传统方式可能涉及复杂API调用,而开源Ollama简化了本地部署。技术栈选择上,前端采用React结合Tailwind CSS和Axios,实现响应式UI和网络交互;后端使用Express框架提供API服务;AI部分则集成Ollama,确保模型运行高效且本地化。这不仅仅是代码堆砌,更是关于模块化、容错和跨域处理的综合实践。

接下来,我们将剖析项目架构、代码实现和关键知识点,包括自定义Hook、API管理、提示工程等。通过提供的代码示例,读者可以轻松复现,并根据需要扩展为更复杂的应用,如代码审查或内容生成工具。

项目架构概述

项目采用前后端分离设计,确保各部分独立开发和部署。

  • 前端:浏览器端运行(默认端口5173),处理用户输入和显示AI响应。使用React构建,借助自定义Hook封装逻辑,避免组件复杂化。Axios用于发送请求到后端。
  • 后端:Node.js与Express框架,监听3000端口,提供RESTful API。核心接口/chat接收消息,调用AI模型后返回结果。集成CORS中间件支持跨域。
  • AI集成:Ollama部署DeepSeek-R1:8b模型(端口11434),通过LangChain构建提示链。模型温度设为0.1,确保输出稳定。

这种架构优势在于可扩展性:前端专注交互,后端管理业务,AI作为服务可独立优化。启动时,前端用Vite工具,后端Node运行,Ollama后台启动模型。

前端实现详解

前端核心是创建一个简洁界面,发送消息并展示AI回复。假设初始消息为模拟输入,实际可扩展为用户表单。

App.jsx:主组件

主组件使用Hook获取数据,渲染加载状态或内容:

jsx

import { useEffect } from 'react';
import { useGitDiff } from './hooks/useGitDiff.js'

export default function App() {
  const { loading, content } = useGitDiff('hello');

  return (
    <div className="flex">
      {loading ? 'loading...' : content}
    </div>
  )
}

这里,Hook接收参数(模拟消息),返回loading和content。组件逻辑简单:加载中显示提示,否则渲染回复。结合Tailwind CSS的flex类,实现响应式布局。

useGitDiff.js:自定义Hook

Hook封装状态和副作用:

jsx

import { useState, useEffect } from 'react'
import { chat } from '../api/index.js'

export const useGitDiff = () => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    (async () => {
      setLoading(true);
      const { data } = await chat('你好');
      setContent(data.reply);
      setLoading(false);
    })()
  }, [])
  return {
    loading,
    content,
  }
}

使用useState管理状态,useEffect异步调用API。模拟消息“你好”,实际可动态传入。返回对象供组件使用,实现数据驱动渲染。

api/index.js:API管理

统一管理请求:

jsx

import axios from 'axios';

const service = axios.create({
  baseURL: 'http://localhost:3000',
  headers: {
    'Content-Type': 'application/json',
  },
  timeout: 120000,
});

export const chat = (message) => service.post('/chat', {message})

Axios实例设置baseURL、headers和timeout。chat函数封装POST请求,便于复用。

前端设计强调简洁,易于添加输入框扩展为完整聊天UI。

后端实现详解

后端使用Express搭建服务器,支持AI调用。

index.js:服务器文件

代码如下:

JavaScript

import express from 'express';
import cors from 'cors';
import { ChatOllama } from '@langchain/ollama';
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const model = new ChatOllama({
  baseURL: "http://localhost:11434",
  model: "deepseek-r1:8b",
  temperature: 0.1
})

const app = express();
app.use(cors());
app.use(express.json());

app.get('/hello', (req, res) => {
  res.send('hello world');
})

app.post('/chat', async (req, res) => {
  console.log(req.body, "//////");
  const { message } = req.body;
  if (!message || typeof message !== 'string') {
    return res.status(400).json({
      error: "message 必填,必须是字符串"
    })
  }
  try {
    const prompt = ChatPromptTemplate.fromMessages([
      ["system", "You are a helpful assistent."],
      ["human", '{input}']
    ])
    const chain = prompt.pipe(model).pipe(new StringOutputParser());
    console.log("正在调用大模型");
    const result = await chain.invoke({
      input: message,
    })
    res.json({
      reply: result
    })
  } catch (err) {
    console.log(err);
    res.status(500).json({
      err: "调用大模型失败"
    })
  }
})

app.listen(3000, () => {
  console.log('server is running on port 3000');
})

初始化Ollama模型。app实例使用CORS和JSON中间件。GET /hello测试路由。POST /chat校验message,构建提示链调用模型,返回reply。容错处理确保稳定性。

关键知识点:跨域与中间件

跨域问题是前后端分离的痛点。浏览器同源策略阻塞不同端口请求。使用cors中间件解决,后端允许前端访问。

中间件链:请求经CORS、JSON解析后到达路由。Express的灵活性便于添加日志或认证。

关键知识点:HTTP与路由

HTTP基于请求响应。GET无body,POST适合传输消息。响应码如400、500指示状态。

路由定义资源访问:app.post处理异步AI调用。

AI集成详解

Ollama提供本地API,LangChain简化提示:系统角色定义,用户输入占位。链式pipe确保输出解析。

温度0.1减少随机性。扩展可自定义提示,如添加上下文。

项目优化

  • 用户输入:前端添加表单动态消息。
  • 安全:添加校验或限流。
  • 部署:容器化Ollama,后端云托管。
  • 扩展:多轮对话或工具调用。

结语

通过集成 Ollama 的全栈应用,我们看到AI如何赋能开发。欢迎讨论优化思路,一起推动开源生态。

富文本编辑器在 AI 时代为什么这么受欢迎

作者 Moment
2026年2月8日 22:21

大家好👋,我是Moment,目前我正在使用 NextJs,NestJs,langchain 开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点,如果你对这个项目感兴趣,可以添加我微信 yunmz777 了解更多详细的信息,如果觉得不错欢迎 star ⭐️⭐️⭐️。

在 2026 年的今天,富文本编辑器已经不再是单纯的打字框,它演变成了人类与 AI 协作的核心战场。

如果说过去十年是 Markdown 和纯文本的极客复兴,那么 AI 时代的到来,则让富文本编辑器重新夺回了统治地位。

AI 就在光标处

在 AI 普及之前,我们写作是写一段,去 ChatGPT 问一段,再复制回来。这种上下文切换是效率的杀手。

现在的富文本编辑器,如 Notion AI、Lex、WPS AI 等,将 AI 直接植入光标。你只需输入斜杠或空格,AI 就能根据前文自动续写、润色或改变语气。富文本编辑器能够理解文档的层级结构,包括标题、段落、列表,这让 AI 能更精准地执行总结这一段或把这部分转成表格的操作。

结构化数据的转换站

AI 最擅长的事情之一,就是将非结构化信息转化为结构化内容。富文本编辑器的块状结构完美适配了这一点。

你丢给 AI 一堆乱七八糟的会议纪要,富文本编辑器能瞬间将其渲染成带有看板、待办列表和甘特图的精美文档。AI 生成的不再只是文字,还有图表、代码块,甚至动态组件。富文本编辑器是承载这些复杂对象的最佳容器。

终结空白页恐惧症

对于创作者来说,最痛苦的是面对一张白纸。AI 时代的富文本编辑器变成了半自动驾驶。

AI 不再是取代作者,而是成了最好的二号位。它帮你打草稿,你负责做决策和注入灵魂。输入一个主题,AI 自动生成大纲和初稿,用户的工作从无中生有变成了审阅与精修。当你逻辑断层时,AI 可以在侧边栏提醒你,甚至帮你查找事实数据,省去了反复跳出窗口搜资料的麻烦。

传统与 AI 原生的分野

传统富文本编辑器的定位是静态记录工具,核心交互靠键盘输入和顶部工具栏,内容处理停留在简单的字体加粗、颜色修改,逻辑理解仅能识别字符和 HTML 标签,扩展性依赖插件系统。

AI 原生富文本编辑器的定位是动态协作伙伴和内容引擎,核心交互靠自然语言指令和斜杠命令,内容处理涵盖自动排版、风格迁移、跨语言同步翻译,逻辑理解能把握段落意图、自动提取任务项,扩展性支持 AI Agent 接入,可调用外部 API 填充数据。

协作维度的升华

以前的协作是人与人在文档里留言。现在的协作是人、AI、人三者联动。

当你打开一份长文档,AI 会为你总结其他人修改了什么。团队中的成员写出的内容风格不一时,AI 可以一键将全篇统一为公司标准公文包风格或互联网黑话风格。

降低专业感的门槛

富文本编辑器通过 AI 让普通人也能做出有大片感的内容。

以前需要懂点设计才能排得好看,现在只需说帮我把这个方案做得像麦肯锡的报告,编辑器会自动调整字体间距、引用格式和配色方案。语音转文字、文字转视觉,AI 在富文本背后的各种转换逻辑,让创作变得从未如此简单。

为什么不是 Word?

Word 几乎是富文本的代名词,但当我们说 AI 时代富文本编辑器火了时,大家脑子里浮现的通常是 Notion、Lex、Linear 这种现代化的编辑器,而不是那个陪了我们几十年的 Microsoft Word。

原因很简单:Word 是为了打印设计的,而 AI 时代的编辑器是为了信息流动设计的。

纸张思维与块思维

Word 的逻辑本质上是在模拟一张 A4 纸。所有的排版,页边距、分页符、行间距,都是为了最终打印出来好看。

AI 时代的逻辑则是原子化的。现代编辑器如 Notion,每一段话、一张图、一个表格都是一个独立的块。AI 很难理解 Word 那种长达 50 页、格式复杂的 XML 结构。但在块状编辑器里,AI 可以精准地知道:我现在的任务是只针对这一个待办事项块进行扩充,或者把这个文本块转化成代码块。这种精细度的控制,Word 很难做到。

功能堆砌与对话驱动

Word 拥有成千上万个功能,埋藏在密密麻麻的菜单栏里。在 Word 里用 AI,你得去点插件、点侧边栏。

现代编辑器奉行极简主义。界面通常只有一张白纸,所有的功能都通过一个斜杠指令或 AI 对话框呼出。在 Word 里,你是找功能;在 AI 编辑器里,你是下指令。当 AI 已经能帮你调格式、改语气时,Word 顶端那几百个图标反而成了视觉噪音。

数据结构的开放性

Word 是孤岛。docx 文件是一个封装好的压缩包。虽然现在有云端版,但它的数据很难实时与其他工具,如任务管理、数据库、代码库,无缝打通。

现代富文本编辑器往往是 All-in-one。AI 在编辑器里写完一个方案,可以直接将其中的任务转化为看板上的卡片。Word 的数据相对静态,而现代编辑器的内容是活着的,AI 可以轻松地跨页面、跨库调用数据。

协作的实时性与轻量化

Word 诞生于离线时代。即便现在的 OneDrive 协作已经进步很大,但其底层的冲突合并机制依然不如原生 Web 编辑器流畅。AI 时代的创作往往是高频次、碎片化、多人多机协作的,现代富文本编辑器原生支持网页访问,AI 可以在你和同事讨论时实时介入,这种流畅度是老牌软件难以企及的。

从设计目标看,Microsoft Word 面向排版与打印,内容单位是页面;AI 原生编辑器如 Notion、Lex 面向思考与协作,内容单位是块或组件。Word 的 AI 角色是辅助插件,Copilot 是外挂;AI 编辑器的 AI 是核心驱动力,是原生系统的一部分。典型动作上,Word 用户设置页边距、调整字体大小;AI 编辑器用户用斜杠总结、空格键续写。视觉感受上,Word 像是一本沉重的精装书,AI 编辑器像是一张无限延伸的草稿纸。

心理层面的创作压力

这听起来很玄学,但真实存在。

打开 Word,你会觉得自己在写公文,潜意识里会去纠正格式;打开 Notion 或 Lex,你会觉得自己在记录想法。AI 最强大的地方在于辅助灵感爆发,而现代富文本编辑器那种无负担的界面,比 Word 更适合作为 AI 的载体。

结语

当然,Word 也在努力。微软推出的 Microsoft Loop 就在全盘致敬这种块状编辑器逻辑,试图把 Word 的强大功能塞进 AI 时代的瓶子里。

富文本编辑器在 AI 时代受欢迎,是因为它已经从一个容器进化成了一个理解器。它不再只等着你喂数据,而是开始主动帮你处理、组织和美化信息。

当人类与 AI 的边界越来越模糊,那个让我们与智能体并肩写作的编辑器,或许才是这个时代真正的创作伙伴。

AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用

作者 Cobyte
2026年2月8日 21:45

1. 引言

在大语言模型(LLM)快速发展的今天,几乎所有产品都在借助大模型进行重塑与升级。在过去一段时间,各类旨在提升效率的 AI Agent 如雨后春笋般涌现,尤其是 Coding Agent 的兴起,在一定程度上对前端开发者的职业前景带来了冲击与挑战。一些走在行业前沿的公司甚至开始提倡“前后端再度融合”,这意味着未来开发者可能需要向具备 AI 能力的全栈工程师转型。因此,掌握 AI 全栈相关的知识与技能变得非常重要。

本文将带你通过实战,从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。另外我们将使用 DeepSeek 作为底层模型进行学习。

技术栈前瞻

  • 后端: Python 3, FastAPI (Web 框架), LangChain (LLM 编排), Uvicorn (ASGI 服务器)
  • 前端: Vue 3, TypeScript, Vite (构建工具)
  • 模型: DeepSeek API (兼容 OpenAI 格式)

我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

2. 为什么选择 Python ?

在 AI 领域,Python 无疑是首选的开发语言。因此,如果想通过学习 AI 全栈技术来获得一份理想工作,掌握 Python 几乎是必经之路。这就像在国内想从事后端开发,Java 绝对是不二之选。对于前端背景的同学而言,虽然也可以通过 Node.js 入门 AI 开发,但就整体就业前景和发展空间来看,跟 Node.js 相比 Python 的优势也是断层领先。同时,Python 作为一门入门门槛较低的语言,学习起来相对轻松,所以大家无需过于担心学习难度问题。

最后本人提倡在实战中学习 Python,并且完全可以借助 AI 进行辅导学习。

2. Python 环境配置

我们这里只提供 Windows 环境的讲解,其他的环境自行 AI,Python 的环境搭建还是十分简单的。

  1. 访问官网下载安装包

www.python.org/downloads/

选择对应的平台版本:

image.png

  1. 安装时勾选 "Add Python to PATH"

image.png

  1. 验证安装

打开终端命令工具输入以下命令行:

python --version
pip --version

出现如下信息则说明安装成功了。

image.png

最后编辑器我们可以选择 VS Code,只需在拓展中安装以下插件即可。

image.png

我们前面说到了我们是使用 DeepSeek 作为底层模型进行学习,所以我们需要去 DeepSeek 的 API 开放平台申请一个大模型的 API key。申请地址如下:platform.deepseek.com/api_keys 。当然我们需要充一点钱,就充几块也够我们学习用了。

3. Python 快速入门

3.1 Hello World

我们创建一个 simple-llm-app 的项目目录,然后在根目录创建一个 .env 文件,用于存放项目的环境变量配置,跟前端的项目一样。我们这里设置上面申请到的 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

然后我们可以通过 python-dotenv 库读从 .env 文件中读取它,我们创建一个 test.py 的文件,里面的代码如下:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 打印
print(os.getenv("DEEPSEEK_API_KEY"))

其中 dotenv 库需要安装 python-dotenv 依赖,安装方法也跟安装 npm 包类似,命令如下:

pip install python-dotenv

接着执行 test.py 文件,执行命令跟 Node.js 类似:

python test.py

我们就可以在命令终端看到 .env 文件中的 DeepSeek API key 了。这算是成功输出了 Python 的 Hello world。

3.2 Python 语法入门

接着我们继续了解 Python 的相关语法。在 Python 中,使用 from ... import ...,在 ES6 JavaScript 中,我们使用 import ... from ...。 所以上述代码的 import os -> 类似于 Node.js 中的 import * as os from 'os'os 是一个内置库。 from dotenv import load_dotenv 则类似于从 npm 包中导入一个类,比如: import load_dotenv from 'dotenv'

Python:没有显式的变量声明关键字,直接通过赋值创建变量。

# Python - 直接赋值,无需关键字
name = "张三"
AGE = 25 # 常量(约定)没有内置的常量类型,但通常用全大写变量名表示常量,实际上可以修改
is_student = True

JavaScript:使用 varlet 或 const 声明变量。

// JavaScript - 必须使用关键字
let name = "张三";
const age = 25;  // 常量 使用 `const` 声明常量,不可重新赋值。
var isStudent = true;  // 旧方式

注释对比

Python注释:

  • 单行注释:以 # 开头
# 这是一个Python单行注释
name = "张三"  # 这是行尾注释
  • 多行注释:可以使用三个单引号 ''' 或三个双引号 """ 包裹
'''
这是一个Python多行注释
可以跨越多行
实际上这是字符串,但常用作注释
'''

"""
双引号三引号也可以
这在Python中通常用作文档字符串(docstring)
"""

JavaScript 注释:

  • 单行注释:以 // 开头
// 这是一个JavaScript单行注释
let name = "张三";  // 这是行尾注释
  • 多行注释:以 /* 开头,以 */ 结尾
/*
 这是一个JavaScript多行注释
 可以跨越多行
 这是真正的注释语法
*/


/**
 * 用户类,表示系统中的一个用户
 * @class
 */
class User {
}

好了我们不贪杯,实战中遇到不同的 Python 语法,我们再针对学习或者借助 AI 通过与 JavaScript 语法进行横向对比,对于有一定编程基础的我们,肯定非常容易理解的。相信通过上述 Python 语法的学习,聪明的你再回头看上述示例的 Python 代码,肯定可以看懂了。

我们这里只是简单介绍上面代码中涉及到的 Python 语法,本人推荐在实战中进行学习。更多 JavaScript 视觉学习 Python:langshift.dev/zh-cn/docs/…

3.3 FastAPI 框架快速入门

3.3.1 FastAPI 是什么

FastAPI 是一个现代、高性能(与 NodeJS 和 Go 相当)的 Web 框架,用于构建 API,基于 Python 3.6+ 并使用了标准的 Python 类型提示。但它本身不提供完整的 Web 服务器功能,而是通过 ASGI(Asynchronous Server Gateway Interface)与服务器进行通信。

Uvicorn 是一个高性能的 ASGI 服务器,它支持异步编程,能够运行 FastAPI 这样的异步 Web 应用。所以 FastAPI 需要配合 Uvicorn 使用,这样才能够充分发挥 FastAPI 的异步特性,提供极高的性能。同时,Uvicorn 在开发和部署时都非常方便。

简单来说

  • FastAPI 负责:路由、验证、序列化、依赖注入等应用逻辑
  • Uvicorn 负责:HTTP 协议解析、并发处理、连接管理等服务器功能

两者结合形成了现代 Python Web 开发的黄金组合,既能享受 Python 的便捷,又能获得接近 Go、Node.js 的性能。

3.3.2 基本示例

我们创建一个 server.py 文件,输入以下示例代码:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。"}

# 程序的入口点
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.1.1.1", port=9527)

上述代码引用了两个依赖 fastapi 和 uvicorn,我们通过 pip 进行安装一下:

pip install fastapi uvicorn

然后我们在终端启动服务:python server.py,运行结果如下:

image.png

接着我们在浏览器打开 http://127.1.1.1:9527 显示如下:

image.png

3.3.3 路径参数和查询参数

示例:

@app.get("/items/{id}")
def read_item(
    id: int, 
    limit: int = 10,         # 默认值
    q: Optional[str] = None, # 可选参数
    short: bool = False,     # 默认值
    tags: List[str] = []     # 列表参数
):
    item = {"id": id, "limit": limit, "tags": tags}
    if q:
        item.update({"q": q})
    if not short:
        item.update({"desc": "长说明"})
    return item

重启服务,在浏览器输入:http://127.1.1.1:9527/items/1?q=cobyte ,结果如下:

image.png

总结

  • 路径参数:在路径中声明的参数,如 id
  • 查询参数:作为函数参数,但不是路径参数,将自动解释为查询参数。
3.3.4 FastAPI 中的模型定义

在 FastAPI 中,我们经常需要处理来自客户端的请求数据,例如 POST 请求的 JSON 体。为了确保数据的正确性,我们需要验证数据是否符合预期的格式和类型。使用 Pydantic 模型可以让我们以一种声明式的方式定义数据的结构,并自动进行验证。

Pydantic 是一个 Python 库,用于数据验证和设置管理,主要基于 Python 类型提示(type hints)。它可以在运行时提供类型检查,并且当数据无效时提供详细的错误信息。

Pydantic 的核心功能是定义数据的结构(模型),并自动验证传入的数据是否符合这个结构。它非常适用于以下场景:

  • 验证用户输入(例如 API 请求的数据)
  • 配置管理
  • 数据序列化和反序列化(例如将 JSON 数据转换为 Python 对象)

Pydantic 模型使用 Python 的类来定义,类的属性使用类型注解来指定类型,并且可以设置默认值。

请求体(Request Body)和响应模型(Response Model)的示例如:

from pydantic import BaseModel, validator, Field
from typing import Optional, List
import re

# 请求体(Request Body)
class UserRequest(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    password: str
    email: str
    @validator('username')
    def username_alphanumeric(cls, v):
        if not re.match('^[a-zA-Z0-9_]+$', v):
            raise ValueError('只能包含字母、数字和下划线')
        return v
    
    @validator('email')
    def email_valid(cls, v):
        if '@' not in v:
            raise ValueError('无效的邮箱地址')
        return v.lower()  # 转换为小写
    
    @validator('password')
    def password_strong(cls, v):
        if len(v) < 6:
            raise ValueError('密码至少6位')
        return v
# 响应模型(Response Model)
class UserResponse(BaseModel):
    username: str
    email: str

@app.post("/user/", response_model=UserResponse)
async def create_user(user: UserRequest):
    # 密码会被过滤,不会出现在响应中
    return user

FastAPI 会自动从 Pydantic 模型生成 API 文档,我们在 server.py 文件中添加了上述示例之后,重启服务,访问 http://127.1.1.1:9527/docs 可以看到:

image.png

并且我们还可以在文档地址中进行测试,这里就不展开讲了。

3.3.5 异步和中间件

示例:

from fastapi import Request

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Process-Time"] = str(process_time)
    return response

我们可以看到 Python 的这个异步语法跟 JavaScript 的 async/await 是一样的语法。

3.3.6 CORS 配置

通过设置 CORS 配置允许前端跨域访问。

from fastapi.middleware.cors import CORSMiddleware
# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],  # 允许的方法
    allow_headers=["*"],  # 允许的头部
)

到此本文所用到的 FastAPI 知识就基本介绍完毕了,后续再在实战中进行学习,先上了 AI 全栈的车再说。

4. LLM 和 OpenAI 接口快速入门

4.1 入门示例代码

让我们从安装依赖开始,借助 DeepSeek 大模型一起探索 OpenAI 接口规范。

pip install openai

接着我们在 test.py 中添加如下代码:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 加载 OpenAI 库,从这里也可以看到 Python 的库加载顺序跟 JavaScript ES6 import 是不一样,反而有点像 requrie
from openai import OpenAI

# 初始化客户端
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"), # 身份验证凭证,确保你有权访问 API
    base_url="https://api.deepseek.com" # 将请求重定向到 DeepSeek 的服务器(而非 OpenAI)
)
# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ]
)
# 打印结果
print(response.choices[0].message.content.strip())

终端输出结果如下:

image.png

可以看到我们成功调用了 DeepSeek 大模型。

在 openai 中,返回的 response 对象是一个 Pydantic 模型,如果我们想详细查看 response 返回的结果,可以使用它自带的 .model_dump_json() 方法。

# 使用 model_dump_json 并指定缩进
print(response.model_dump_json(indent=2))

可以看到通过上述方式打印大模型响应的信息如下:

image.png

4.2 choices 字段详解

我们从上面打印的结果可以了知道,大模型返回的文本信息是存储在 choices 字段中的,所以我们来了解一下它。

在调用 chat.completions.create 时,如果设置了 n 参数(n>1),那么模型会生成多个输出,此时 choices 字段就会包含多个元素。每个 choice 代表一个可能的响应,我们可以通过遍历 choices 来获取所有响应。

另外,即使 n=1(默认值),choices 也是一个列表,只不过只包含一个元素。所以我们上述例子中才通过 response.choices[0] 来获取大模型的返回结果。

4.3 流式响应

因为大模型本质上是一个预测生成器,简单来说就是你输入一句话,大模型就预测下一个字。因此我们希望在模型生成文本的同时就显示给用户,提高交互的实时性。这就是流式响应。代码设置如下:

# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ],
+  stream=True, # 启用流式传输
)

+# response是一个生成器,在Python中,生成器是一种迭代器,每次迭代返回一个值。这里,每次迭代返回一个chunk(部分响应)。
+for chunk in response:                           # 1. 遍历响应流
+    if chunk.choices[0].delta.content:           # 2. 检查是否有内容
+        print(chunk.choices[0].delta.content,    # 3. 打印内容
+              end="",                            # 4. 不换行
+              flush=True)                        # 5. 立即刷新缓冲区

输出结果如下:

20260207-132313.gif

4.4 temperature 参数

我个人觉得那么多大模型参数中 temperature 参数还是比较重要的,值得我们了解一下。模型在生成每一个词时,都会计算一个所有可能的下一个词的概率分布(例如,“苹果”概率0.3,“香蕉”概率0.5,“水果”概率0.2)。temperature 的值会影响这个概率分布的形状,从而改变模型最终根据这个分布进行“抽样”选择的结果。

一个简单的比喻:选餐厅吃饭

  • Temperature = 0.0永远只去评分最高、去过无数次的那一家最保险的餐厅。结果最稳定,但永远没有新体验。
  • Temperature = 1.0大多数时候去那家最好的,但偶尔也会根据评价试试附近其他不错的餐厅。平衡了可靠性和新鲜感。
  • Temperature = 1.5经常尝试新餐厅,甚至包括一些评价奇特或小众的地方。体验非常丰富,但有时会“踩雷”。

总结与建议

  1. 追求确定性时调低 (接近0) :当你需要精确、可靠、可复现的结果时,如生成代码、数学推导、事实问答、指令严格遵循。
  2. 追求创造性和多样性时调高 (>1.0) :当你需要创意、多样化表达、故事生成、诗歌时。
  3. 通用场景用中间值 (0.8-1.2) :大多数对话、摘要、分析等任务,这个范围能提供既连贯又有一定灵活性的输出。

4.5 消息角色

在 OpenAI API 中,messages 数组中的每条消息都有一个 role 字段,它定义了消息的来源和用途。消息角色主要有三种:system、user、assistant。此外,在后续的更新中,还引入了 tool 和 function 等角色,但最基础的是前三种。

1. system (系统)

  • 作用: 设置助手的背景、行为、指令等。

  • 特点:

    • 通常作为第一条消息,用于设定对话的上下文和规则。
    • 不是必须的,但可以显著影响助手的行为。
  • 示例:

    {"role": "system", "content": "你是一个专业的翻译助手,只能将中文翻译成英文,其他问题一律不回答。"}
    

2. user (用户)

  • 作用: 用户输入的问题或指令

  • 特点:

    • 代表对话中的人类用户
    • 每个请求必须至少包含一条 user 消息
    • 通常是最后一条消息(除了流式响应)
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个有帮助的助手"},
        {"role": "user", "content": "什么是机器学习?"}
    ]
    

3. assistant (助手)

  • 作用: 代表助手之前的回复。

  • 特点:

    • 在多轮对话中保存历史回复
    • 帮助模型保持对话连贯性
    • 在单轮对话中不需要此角色
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个数学老师"},
        {"role": "user", "content": "2+2等于多少?"},
        {"role": "assistant", "content": "2+2等于4"},
        {"role": "user", "content": "那3+3呢?"}  # 模型知道这是新问题
    ]
    

通过合理组合这些角色,你可以构建从简单问答到复杂多轮对话的各种应用场景。记住:清晰的角色定义和恰当的消息组织是获得高质量回复的基础。我们这里先介绍前三种核心角色。

5. LangChain 入门

5.1 怎么理解 LangChain 框架

从前端的视角来理解,LangChain 就好比是 Vue 或 React 这类框架。在前端开发中,如果没有 Vue 或 React,我们就需要直接编写大量操作浏览器 API 的底层代码;而有了这类框架,它们封装了常见的交互逻辑和状态管理,让开发变得更高效、更结构化。类似地,LangChain 实际上是一套封装了大型语言模型常见操作模式的方案,它帮助开发者更便捷地调用、组合与管理大模型的能力,而无需每次都从头编写复杂的模型交互代码。

5.2 LangChain 调用 LLM 示例

接着我们在项目根目录下创建一个 llm-app.py 文件,输入以下内容:

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv

# 1. 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()

# 2. 创建组件
# 相对于上面的使用 OpenAI 的接口,现在经过 LangChain 封装后确实简洁了很多
llm = ChatOpenAI(
    model="deepseek-chat", 
    temperature=0.7,
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1"
)

# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
prompt = ChatPromptTemplate.from_template("{question}")

# 创建解析器
parser = StrOutputParser()
# 将AI响应转换为字符串,通过前面的知识我们知道大模型返回的数据一般包含很多数据,
# 很多时候我们只需要其中的文本内容。`StrOutputParser` 就是用来提取这个文本内容的

# 3. 组合链 (LCEL 语法) Python LangChain 常见的链式调用
chain = prompt | llm | parser
# 等价于:输入 → 模板填充 → AI处理 → 结果解析

# 4. 执行
result = chain.invoke({"question": "你是谁?"})
# 内部执行:填充"你是谁?" → 调用API → 解析响应 → 返回字符串

# 5. 打印结果
print(result)

然后在终端安装对应的依赖(这个步骤跟前端也很像,所以学习 Python 是很简单的):

pip install langchain_openai langchain_core dotenv

接着在终端执行

# 跟前端的 node llm-app.js 等价
python llm-app.py

终端输出结果如下:

image.png

可以看到我们成功执行了一个 Python + LangChain 的应用程序。

5.2 消息模板系统

我们上面的注释讲解了 prompt = ChatPromptTemplate.from_template("{question}") 这句代码默认创建了一个人类角色的提示模板,也就是 {"role": "user", "content": "用户输入的内容"}

LangChain 作为一个强大的 LLM 应用开发框架, 为了让开发者能够精确控制对话的流程和结构,提供了灵活且强大的消息模板系统。LangChain 的消息模板系统基于角色(role)的概念,将对话分解为不同类型的信息单元。目前的类型如下:

角色 用途 对应 OpenAI 角色
SystemMessagePromptTemplate system 系统指令、设定 system
HumanMessagePromptTemplate human 用户输入 user
AssistantMessagePromptTemplate assistant AI 回复 assistant
AIMessagePromptTemplate ai AI 回复(别名) assistant
ToolMessagePromptTemplate tool 工具调用结果 tool
FunctionMessagePromptTemplate function 函数调用结果 function

ChatPromptTemplate 则是消息系统的核心容器,负责协调各种消息类型:

from langchain_core.prompts import (
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
    AssistantMessagePromptTemplate
)
system = SystemMessagePromptTemplate.from_template(...)
human = HumanMessagePromptTemplate.from_template(...)
assistant = AssistantMessagePromptTemplate.from_template(...)
prompt = ChatPromptTemplate.from_messages([system, human, assistant])

所以上述入门实例代码可以进行以下修改:

-from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.prompts import ChatPromptTemplate,HumanMessagePromptTemplate
# 省略...
-# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
-prompt = ChatPromptTemplate.from_template("{question}")
+human = HumanMessagePromptTemplate.from_template("{question}")
+prompt = ChatPromptTemplate.from_messages([human])
# 省略...

然后重新在终端执行 python llm-app.py 依然正常输出。

同时通过 LangChain 消息模型来理解大模型的调用过程也变得十分的清晰,所以整个流程是:

输入 → prompt → llm → parser → 输出
     ↓
{"question": "你是谁?"}
     ↓
prompt 处理:创建消息 "你是谁?"
     ↓
llm 处理:调用 LLM 处理,返回 AIMessage 对象
     ↓
parser 处理:提取文本内容
     ↓
最终结果字符串

在 LangChain 中还有一个最基础的模板类 PromptTemplate 用于构建字符串提示。下面我们也来了解一下它的基本用法。

from langchain_core.prompts import PromptTemplate

# 方式1:使用 from_template 类方法(最常用)
prompt = PromptTemplate.from_template("请解释什么是{concept}。")

# 方式2:直接实例化
prompt = PromptTemplate(
    input_variables=["concept"], 
    template="请解释什么是{concept}。"
)

综上所述我们通过理解和掌握 LangChain 这些核心概念,才能高效地构建可靠、可维护的 LLM 应用。此外,LangChain 的消息模板系统仍在不断发展当中,我们需要不断地持续关注。

5.3 LangChain 链式调用(管道操作符)

在 LangChain 中所谓的链式调用是通过管道操作符 | 来实现的,也就是通过 | 实现将一个函数的输出作为下一个函数的输入。

例如上述的示例代码中的:

# LangChain 中的管道操作
chain = prompt | llm | output_parser
  • 等价于手动执行链的每一步:
# 第一步:prompt 处理
messages = prompt.invoke({"question": "你是谁?"})
# messages = [HumanMessage(content="你是谁?")]

# 第二步:llm 处理
response = llm.invoke(messages)
# response = AIMessage(content="我是DeepSeek...")

# 第三步:parser 处理
result = parser.invoke(response)
# result = "我是DeepSeek..."

在标准 Python 语法中,| 是按位或操作符,用于:

  • 整数的按位运算:5 | 3 = 7
  • 集合的并集运算:{1, 2} | {2, 3} = {1, 2, 3}
  • 从 Python 3.10 开始,用于类型联合:int | str

但 LangChain 通过 重载(overload)  | 操作符,赋予了它新的含义:

  • | 在 LangChain 中是一种语法糖,让链式操作更直观
  • 它不是 Python 的新语法,而是通过操作符重载实现的框架特定功能
  • 这种设计让 LangChain 的代码更加简洁和易读

6. LLM 聊天应用后端

6.1 后端架构设计

我们遵循单一职责原则(SRP)进行分层架构设计,将系统划分为API层、业务层和数据层,旨在实现高内聚、低耦合,提升代码的可维护性、可测试性和可扩展性。

API层  专注于处理 HTTP 协议相关的逻辑,包括路由定义、请求验证、响应序列化和跨域处理等。它作为系统的入口点,负责与客户端进行通信,并将业务逻辑委托给下层。这种设计使得我们可以独立地调整 API 暴露方式(如支持 WebSocket)而不影响核心业务逻辑。

业务层  封装 LLM 的核心应用逻辑,例如与 AI 模型的交互、对话历史管理和流式生成等。这一层独立于 Web 框架,使得业务逻辑可以复用于其他场景(如命令行界面或批处理任务)。同时,业务层的单一职责确保了我们能够针对 LLM 交互进行优化和测试,而无需关心 HTTP 细节。

数据层  通过 Pydantic 定义系统的数据模型,包括请求、响应结构和内部数据传输对象。通过集中管理数据模型,我们确保了数据格式的一致性,并便于进行数据验证和类型提示。这种分离使得数据结构的变更更加可控,同时也为生成 API 文档提供了便利。

6.1 实现业务层和数据层

实现业务层其实就是封装 LLM 的核心应用逻辑。通过将复杂的 LLM 调用逻辑、提示工程和流式处理封装在独立的类中,这样 API 层只需关注请求与响应,而无需了解 LangChain 或特定 API 的细节。这使得底层技术栈的迭代或更换(例如从 LangChain 切换到其他操作大模型的框架或更改 LangChain 最新的 API)变得轻而易举,只需修改封装类内部实现,而对外接口保持不变,实现了有效隔离。

创建 ./backend/llm_app.py 文件,内容如下:

import os
from typing import Generator
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 加载环境变量
load_dotenv()

class LLMApp:
    def __init__(self, model_name="deepseek-chat", temperature=0.7):
        """
        初始化 LLM 应用程序
        """
        # 检查 DeepSeek API 密钥
        if not os.getenv("DEEPSEEK_API_KEY"):
            raise ValueError("请在 .env 文件中设置 DEEPSEEK_API_KEY 环境变量")
        
        # 初始化配置
        self.model_name = model_name
        self.temperature = temperature
        self.api_key = os.getenv("DEEPSEEK_API_KEY")
        self.base_url = "https://api.deepseek.com/v1"
        
        # 初始化非流式 LLM (用于普通任务)
        self.llm = self._create_llm(streaming=False)
        
        # 初始化流式 LLM (用于流式对话)
        self.streaming_llm = self._create_llm(streaming=True)
        
        # 输出解析器
        self.output_parser = StrOutputParser()
        
        # 初始化对话链
        self._setup_chains()
    
    def _create_llm(self, streaming: bool = False):
        """创建 LLM 实例"""
        return ChatOpenAI(
            model_name=self.model_name,
            temperature=self.temperature,
            api_key=self.api_key,
            base_url=self.base_url,
            streaming=streaming
        )
    
    def _setup_chains(self):
        """设置处理链"""
        # 带上下文的对话 Prompt
        conversation_prompt = PromptTemplate(
            input_variables=["chat_history", "user_input"],
            template="""你是一个有用的 AI 助手。请根据对话历史回答用户的问题。
            
            对话历史:
            {chat_history}
            
            用户:{user_input}
            助手:"""
        )
        # 注意:这里我们只定义 prompt,具体执行时再组合
        self.conversation_prompt = conversation_prompt

    def format_history(self, history_list) -> str:
        """格式化聊天历史"""
        if not history_list:
            return "无历史对话"
        
        formatted = []
        for msg in history_list:
            # 兼容 Pydantic model 或 dict
            if isinstance(msg, dict):
                role = msg.get('role', 'unknown')
                content = msg.get('content', '')
            else:
                role = getattr(msg, 'role', 'unknown')
                content = getattr(msg, 'content', '')
                
            formatted.append(f"{role}: {content}")
        
        return "\n".join(formatted[-10:])  # 只保留最近 10 条

    def stream_chat(self, user_input: str, chat_history: list) -> Generator[str, None, None]:
        """流式对话生成器"""
        try:
            history_text = self.format_history(chat_history)
            
            # 构建链:Prompt | StreamingLLM | OutputParser
            chain = self.conversation_prompt | self.streaming_llm | self.output_parser
            
            # 执行流式生成
            for chunk in chain.stream({
                "chat_history": history_text,
                "user_input": user_input
            }):
                yield chunk
                
        except Exception as e:
            yield f"Error: {str(e)}"

接下来我们对上述封装的 LLM 类的功能进行测试,测试前先在 ./backend/.env 文件中添加 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

接着创建 ./backend/test.py 文件写上以下测试代码。

from llm_app import LLMApp

# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"

# 收集流式响应
response_chunks = []
for chunk in llmApp.stream_chat(user_input, chat_history):
    response_chunks.append(chunk)
    # 模拟实时显示
    print(chunk, end="", flush=True)

# 合并响应
full_response = "".join(response_chunks)
print(f"\n完整响应: {full_response}")

测试结果如下:

20260208-172852.gif

接着我们通过 Pydantic 来定义数据的结构(模型)

创建 ./backend/models.py 文件,内容如下:

from pydantic import BaseModel
from typing import List, Optional

class ChatMessage(BaseModel):
    """单条聊天消息"""
    role: str  # "user" 或 "assistant"
    content: str

class ChatRequest(BaseModel):
    """聊天请求模型"""
    message: str
    chat_history: Optional[List[ChatMessage]] = []

修改 ./backend/test.py 文件,内容如下:

import json
import asyncio
from llm_app import LLMApp
from models import ChatRequest, ChatMessage


# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"
# 模拟 SSE 的流式聊天响应
async def chat_stream(request: ChatRequest):
    # 1. 发送开始事件
    yield f"data: {json.dumps({'type': 'start'})}\n\n"
    await asyncio.sleep(0.01) # 让出控制权,以便运行其他任务。
    
    full_response = ""
    
    # 2. 生成并发送 token
    for token in llmApp.stream_chat(request.message, request.chat_history):
        full_response += token
        yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
        await asyncio.sleep(0.01)
    
    # 3. 发送结束事件
    yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"

# 异步测试函数
async def test_chat_stream():
    # 使用 Pydantic 模型实现数据序列化和反序列化(即将JSON数据转换为Python对象)
    request = ChatRequest(message=user_input, chat_history=chat_history)
    async for chunk in chat_stream(request):
        print(chunk)
# 在异步编程中,我们使用asyncio.run()来运行一个异步函数(coroutine)作为程序的入口点。
asyncio.run(test_chat_stream())

打印结果如下:

image.png

在上述的测试代码中的 chat_stream 函数实现一个基于 Server-Sent Events (SSE) 的流式聊天响应的异步生成器,它接收一个 ChatRequest 对象,然后逐步生成事件流。事件流格式遵循 SSE 规范,每个事件以 "data: " 开头,后跟 JSON 字符串,并以两个换行符结束。

  1. 首先,发送一个开始事件,通知客户端开始生成响应。
  2. 然后,通过调用 llmApp.stream_chat 方法,逐个获取 token,并将每个 token 作为一个事件发送。
  3. 在发送每个 token 事件后,使用 await asyncio.sleep(0.01) 来让出控制权,这样其他任务可以运行,避免阻塞。
  4. 同时,将每个 token 累加到 full_response 中,以便在最后发送整个响应。
  5. 最后,发送一个结束事件,并包含完整的响应内容。

这样设计的好处:

  • 流式传输:可以逐步将响应发送给客户端,客户端可以实时看到生成的 token,提升用户体验(如打字机效果)。
  • 异步:使用异步生成器,可以在等待模型生成下一个 token 时让出控制权,提高并发性能。
  • 事件驱动:通过定义不同类型的事件(开始、token、结束),客户端可以方便地根据事件类型进行处理。

6.2 实现 API 层

上面测试代码中实现的 chat_stream 函数,其实就是我们接下来要实现的 流式对话接口,即接收用户的消息和聊天历史,通过流式方式返回 LLM 的响应。同时我们再实现一个健康检查接口,提供服务器的健康状态,包括 LLM 应用是否初始化成功、模型名称等,便于监控。

根据上面所学的知识,我们实现一个基于 FastAPI 的 LLM 聊天 API 服务。

我们创建 ./backend/server.py 文件,内容如下:

import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from llm_app import LLMApp
from models import ChatRequest, HealthResponse

app = FastAPI(title="Cobyte LLM Chat API")

# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局 LLM 应用实例
llm_app = None

@app.on_event("startup")
async def startup_event():
    """应用启动时初始化 LLM"""
    global llm_app
    try:
        print("正在初始化 LLM 应用...")
        llm_app = LLMApp()
        print("✅ LLM 应用初始化成功")
    except Exception as e:
        print(f"❌ LLM 应用初始化失败: {e}")

@app.get("/api/health")
async def health_check():
    """健康检查接口"""
    return HealthResponse(
        status="healthy" if llm_app else "unhealthy",
        model="deepseek-chat",
        api_configured=llm_app is not None,
        timestamp=datetime.now().isoformat()
    )

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    """流式对话接口"""
    if not llm_app:
        raise HTTPException(status_code=500, detail="LLM 服务未就绪")
    
    async def generate():
        try:
            # 1. 发送开始事件
            yield f"data: {json.dumps({'type': 'start'})}\n\n"
            await asyncio.sleep(0.01) # 让出控制权
            
            full_response = ""
            
            # 2. 生成并发送 token
            # 注意:llm_app.stream_chat 是同步生成器,但在 FastAPI 中可以正常工作
            # 如果需要完全异步,需要使用 AsyncChatOpenAI,这里为了简单保持同步调用
            for token in llm_app.stream_chat(request.message, request.chat_history):
                full_response += token
                yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
                await asyncio.sleep(0.01)
            
            # 3. 发送结束事件
            yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"
            
        except Exception as e:
            error_msg = str(e)
            print(f"生成错误: {error_msg}")
            yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

至此我们基于 FastAPI 实现了 API 层。核心功能就是提供了两个 API:

  1. 流式对话接口 /api/chat/stream

    • 支持 Server-Sent Events (SSE) 流式响应
    • 接收用户消息,实时返回 AI 生成的回复
    • 支持对话历史管理
  2. 健康检查接口 /api/health

    • 检查服务状态
    • 返回 API 配置信息

6.3 依赖管理

为了更好地管理我们的依赖,我们可以创建一个 ./backend/requirements.txt 文件,将使用到的依赖都设置到这个文件中:

fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0
langchain>=1.2.9
langchain-openai>=0.0.5
pydantic>=2.5.0

这样我们就可以进行以下方式进行安装依赖了。

# 安装依赖
pip install -r requirements.txt

7. 前端聊天界面

先创建一个 Vue3 + TS 的前端项目,我们在根目录下执行以下命令:

npm create vite@latest frontend --template vue-ts

接下来我们主要实现以下核心功能:

  1. 对话界面

    • 消息列表展示(用户消息 + AI 回复)
    • 输入框 + 发送按钮
    • 流式显示 AI 回复(逐字显示效果)
    • 加载状态提示
  2. 交互功能

    • 发送消息(Enter 键/点击按钮)
    • 清空对话历史
    • 滚动到最新消息

./frontend/src/types/chat.ts 文件如下:

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: number
  streaming?: boolean  // 是否正在流式生成
}

export interface ChatRequest {
  message: string
  chat_history: Array<{
    role: string
    content: string
  }>
}

export interface SSEEvent {
  type: 'start' | 'token' | 'end' | 'error'
  content?: string
  full_response?: string
  message?: string
}

./frontend/src/api/chat.ts 文件内容如下:

import type { ChatRequest, SSEEvent } from '../types/chat'

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'

export class ChatAPI {
  /**
   * 流式对话接口
   */
  static streamChat(
    payload: ChatRequest,
    onToken: (token: string) => void,
    onComplete: (fullResponse: string) => void,
    onError: (error: string) => void
  ): () => void {
    // 使用 fetch API 配合 ReadableStream 来处理 POST 请求的流式响应
    // 因为标准的 EventSource 不支持 POST 请求
    const controller = new AbortController()
    
    const fetchStream = async () => {
      try {
        const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(payload),
          signal: controller.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const reader = response.body?.getReader()
        const decoder = new TextDecoder()
        
        if (!reader) throw new Error('Response body is null')

        let buffer = ''

        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          const chunk = decoder.decode(value, { stream: true })
          buffer += chunk
          
          // 处理 buffer 中的每一行
          const lines = buffer.split('\n\n')
          buffer = lines.pop() || '' // 保留最后一个可能不完整的块
          
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const jsonStr = line.slice(6)
              try {
                const data: SSEEvent = JSON.parse(jsonStr)
                
                switch (data.type) {
                  case 'start':
                    break
                  case 'token':
                    if (data.content) onToken(data.content)
                    break
                  case 'end':
                    if (data.full_response) onComplete(data.full_response)
                    return // 正常结束
                  case 'error':
                    onError(data.message || 'Unknown error')
                    return
                }
              } catch (e) {
                console.error('JSON parse error:', e)
              }
            }
          }
        }
      } catch (error: any) {
        if (error.name === 'AbortError') return
        onError(error.message)
      }
    }

    fetchStream()

    // 返回取消函数
    return () => controller.abort()
  }

  /**
   * 健康检查
   */
  static async healthCheck() {
    try {
      const response = await fetch(`${API_BASE_URL}/api/health`)
      return await response.json()
    } catch (error) {
      console.error('Health check failed', error)
      return { status: 'error' }
    }
  }
}

./frontend/src/composables/useChat.ts 文件内容如下:

import { ref, nextTick } from 'vue'
import type { Message } from '../types/chat'
import { ChatAPI } from '../api/chat'

export function useChat() {
  const messages = ref<Message[]>([])
  const isLoading = ref(false)
  const currentStreamingMessage = ref<Message | null>(null)
  
  // 用于取消当前的请求
  let cancelStream: (() => void) | null = null

  /**
   * 滚动到底部
   */
  const scrollToBottom = () => {
    nextTick(() => {
      const container = document.querySelector('.message-list')
      if (container) {
        container.scrollTo({
          top: container.scrollHeight,
          behavior: 'smooth'
        })
      }
    })
  }

  /**
   * 发送消息
   */
  const sendMessage = async (content: string) => {
    if (!content.trim() || isLoading.value) return

    // 1. 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: content.trim(),
      timestamp: Date.now()
    }
    messages.value.push(userMessage)
    
    // 准备发送给后端的历史记录(去掉刚加的这一条,因为后端只要之前的)
    // 或者你可以根据设计决定是否包含当前条,通常 API 设计是:新消息 + 历史
    // 我们的后端设计是:message + chat_history
    const historyPayload = messages.value.slice(0, -1).map(m => ({
      role: m.role,
      content: m.content
    }))

    // 2. 创建 AI 消息占位符
    const aiMessage: Message = {
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      streaming: true
    }
    messages.value.push(aiMessage)
    currentStreamingMessage.value = aiMessage
    isLoading.value = true
    
    scrollToBottom()

    // 3. 调用流式 API
    cancelStream = ChatAPI.streamChat(
      {
        message: content.trim(),
        chat_history: historyPayload
      },
      // onToken
      (token) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += token
          scrollToBottom()
        }
      },
      // onComplete
      (fullResponse) => {
        if (currentStreamingMessage.value) {
          // 确保内容完整
          if (currentStreamingMessage.value.content !== fullResponse && fullResponse) {
             currentStreamingMessage.value.content = fullResponse
          }
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      },
      // onError
      (error) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += `\n[错误: ${error}]`
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      }
    )
  }

  /**
   * 清空历史
   */
  const clearHistory = () => {
    if (cancelStream) {
      cancelStream()
      cancelStream = null
    }
    messages.value = []
    isLoading.value = false
    currentStreamingMessage.value = null
  }

  return {
    messages,
    isLoading,
    sendMessage,
    clearHistory
  }
}

./frontend/src/App.vue 文件内容如下:

<template>
  <div class="app-container">
    <header class="chat-header">
      <div class="header-content">
        <h1>🤖 DeepSeek 对话助手</h1>
        <div class="status-badge" :class="{ online: isServerOnline }">
          {{ isServerOnline ? '在线' : '离线' }}
        </div>
      </div>
      <button @click="clearHistory" class="clear-btn" title="清空对话">
        🗑️
      </button>
    </header>

    <main class="message-list">
      <div v-if="messages.length === 0" class="empty-state">
        <p>👋 你好!我是基于 DeepSeek 的 AI 助手。</p>
        <p>请在下方输入问题开始对话。</p>
      </div>

      <div 
        v-for="msg in messages" 
        :key="msg.id" 
        class="message-wrapper"
        :class="msg.role"
      >
        <div class="avatar">
          {{ msg.role === 'user' ? '👤' : '🤖' }}
        </div>
        <div class="message-content">
          <div class="bubble">
            {{ msg.content }}
            <span v-if="msg.streaming" class="cursor">|</span>
          </div>
        </div>
      </div>
    </main>

    <footer class="input-area">
      <div class="input-container">
        <textarea
          v-model="inputContent"
          placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
          @keydown.enter.exact.prevent="handleSend"
          :disabled="isLoading"
          rows="1"
          ref="textareaRef"
        ></textarea>
        <button 
          @click="handleSend" 
          :disabled="isLoading || !inputContent.trim()"
          class="send-btn"
        >
          {{ isLoading ? '...' : '发送' }}
        </button>
      </div>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useChat } from './composables/useChat'
import { ChatAPI } from './api/chat'

const { messages, isLoading, sendMessage, clearHistory } = useChat()
const inputContent = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isServerOnline = ref(false)

// 检查服务器状态
onMounted(async () => {
  const health = await ChatAPI.healthCheck()
  isServerOnline.value = health.status === 'healthy'
})

// 自动调整输入框高度
watch(inputContent, () => {
  if (textareaRef.value) {
    textareaRef.value.style.height = 'auto'
    textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
  }
})

const handleSend = () => {
  if (inputContent.value.trim() && !isLoading.value) {
    sendMessage(inputContent.value)
    inputContent.value = ''
    // 重置高度
    if (textareaRef.value) {
      textareaRef.value.style.height = 'auto'
    }
  }
}
</script>

<style>
:root {
  --primary-color: #4a90e2;
  --bg-color: #f5f7fa;
  --chat-bg: #ffffff;
  --user-msg-bg: #e3f2fd;
  --bot-msg-bg: #f5f5f5;
  --border-color: #e0e0e0;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  background-color: var(--bg-color);
  height: 100vh;
  overflow: hidden;
}

.app-container {
  max-width: 800px;
  margin: 0 auto;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--chat-bg);
  box-shadow: 0 0 20px rgba(0,0,0,0.05);
}

/* Header */
.chat-header {
  padding: 1rem;
  border-bottom: 1px solid var(--border-color);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: white;
  z-index: 10;
}

.header-content h1 {
  font-size: 1.2rem;
  color: #333;
}

.status-badge {
  font-size: 0.8rem;
  padding: 2px 6px;
  border-radius: 4px;
  background: #ff5252;
  color: white;
  display: inline-block;
  margin-left: 8px;
}

.status-badge.online {
  background: #4caf50;
}

.clear-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 5px;
  border-radius: 50%;
  transition: background 0.2s;
}

.clear-btn:hover {
  background: #f0f0f0;
}

/* Message List */
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.empty-state {
  text-align: center;
  margin-top: 50px;
  color: #888;
}

.message-wrapper {
  display: flex;
  gap: 12px;
  max-width: 85%;
}

.message-wrapper.user {
  align-self: flex-end;
  flex-direction: row-reverse;
}

.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: #eee;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.2rem;
  flex-shrink: 0;
}

.bubble {
  padding: 12px 16px;
  border-radius: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-wrapper.user .bubble {
  background: var(--user-msg-bg);
  color: #0d47a1;
  border-radius: 12px 2px 12px 12px;
}

.message-wrapper.assistant .bubble {
  background: var(--bot-msg-bg);
  color: #333;
  border-radius: 2px 12px 12px 12px;
}

.cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: #333;
  animation: blink 1s infinite;
  vertical-align: middle;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Input Area */
.input-area {
  padding: 20px;
  border-top: 1px solid var(--border-color);
  background: white;
}

.input-container {
  display: flex;
  gap: 10px;
  align-items: flex-end;
  background: #f8f9fa;
  padding: 10px;
  border-radius: 12px;
  border: 1px solid var(--border-color);
}

textarea {
  flex: 1;
  border: none;
  background: transparent;
  resize: none;
  max-height: 150px;
  padding: 8px;
  font-size: 1rem;
  font-family: inherit;
  outline: none;
}

.send-btn {
  background: var(--primary-color);
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
  transition: opacity 0.2s;
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

./frontend/src/style.css 文件内容如下:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

#app {
  width: 100%;
  height: 100vh;
}

.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #1a1a1a;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.header {
  text-align: center;
  margin-bottom: 2rem;
}

.header h1 {
  font-size: 2rem;
  color: #ffffff;
  margin: 0;
}

.header p {
  font-size: 1rem;
  color: #bbbbbb;
  margin: 0;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #ffffff;
  font-size: 0.9rem;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
}

.form-group textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
  resize: vertical;
}

.form-group button {
  width: 100%;
  padding: 0.75rem;
  border: none;
  border-radius: 6px;
  background-color: #4caf50;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.form-group button:hover {
  background-color: #45a049;
}

.error-message {
  color: #ff4d4d;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

.success-message {
  color: #4caf50;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

@media (max-width: 600px) {
  .container {
    padding: 1rem;
  }

  .form-group input,
  .form-group textarea {
    font-size: 0.9rem;
  }

  .form-group button {
    font-size: 0.9rem;
  }
}

前端比较简单,前端部分的实现就不进行详细讲解了。

8. 运行与验证

8.1 启动后端

打开一个终端窗口:

cd backend
# 1. 安装依赖
pip install -r requirements.txt

# 2. 设置 API Key (重要!)
# 编辑 .env 文件,填入你的 DeepSeek API Key
# DEEPSEEK_API_KEY=sk-... 

# 3. 启动服务器
python server.py
# 服务将运行在 http://0.0.0.0:8000

8.2 启动前端

打开一个新的终端窗口:

cd frontend
# 1. 安装依赖
npm install

# 2. 启动开发服务器
npm run dev

访问前端地址,你就可以看到一个简洁的聊天界面。

image.png

当你输入问题并点击发送时,请求会经过: 前端 -> FastAPI -> LangChain -> DeepSeek API -> 返回结果

9. 总结

通过本文,我们完成了一个最小可行性产品(MVP)。从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。

这个项目虽然简单,但它包含了一个 AI 应用的完整骨架。你可以在此基础上扩展更多功能,例如添加对话历史记忆 (Memory)  或 RAG (知识库检索)

接下来我将继续输出更多 AI 全栈的相关知识,欢迎大家关注本栏目。我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

《 Koa.js 》教程 | 一份不可多得的 Node.js 的 Web 框架 Koa.js 教程

2026年2月8日 15:49

第一章 安装和配置 koa

Koa 是一个轻量级、现代化的框架, 由 Express 原班人马开发

初始化配置文件 package.json

npm init -y

配置 package.json (ESM规范)

{
     "type": "module",
     "name": "demo",
     "version": "1.0.0",
     "main": "index.js",
     "scripts": {
          "dev":"nodemon index.js",
           "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [],
     "author": "",
     "license": "ISC",
     "description": ""
}

npm 官网

     www.npmjs.com

安装koa      

npm i koa

     全局安装 nodemon

  .  npm i nodemon -g

     当 nodemon 检测到监视的文件发生更改时, 会自动重新启动应用

第二章 创建并启动 http 服务器

中间件

中间件是处理 HTTP 请求和响应的函数,它们可以做以下操作:

  • 处理请求(例如解析请求体、验证用户身份等)
  • 修改响应(例如设置响应头、发送响应体等)
  • 执行后续中间件

中间件 - 很重要的概念 !!!!!!!

注意 : app.use() 方法用于注册 中间件

中间件 是处理 http 请求和响应的函数 , 当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件

上下文对象 ctx

在 Koa 中,ctx(上下文)对象是每个中间件函数的核心,它包含了请求和响应的所有信息。所有的 HTTP 请求和响应都通过 ctx 进行处理。

上下文对象 ctx ( context ) 包含了与当前 http 请求相关的所有信息

如: http方法、url、请求头、请求体、查询参数等

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

app.use(async ctx => {
    ctx.body = "juejin.cn" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第三章 洋葱模型

洋葱模型

当你处理一个请求时,

可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分

这个过程涉及对 请求 的多个层面进行解析、验证、处理

在处理完洋葱(请求)后,

构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,

从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)

image.png

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

/*
    app.use() 方法用于注册中间件
    中间件是处理 http 请求和响应的函数
    当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件
    
    上下文对象 ctx(context) 包含了与当前 http 请求相关的所有信息
    如: http方法、url、请求头、请求体、查询参数等
*/
app.use(async (ctx,next) => {
    console.log(1)
    await next() //若中间件调用了next(),会暂停当前中间件的执行,将控制权传递给下一个中间件
    console.log(2)
})

app.use(async (ctx,next) => { 
    console.log(3)
    await next()
    console.log(4)
})

//当中间件没有再调用next(),则不需要再将控制权传递给下一个中间件,控制权会按照相反的顺序执行
app.use(async (ctx,next) => {
    console.log(5)
    ctx.body = "dengruicode.com" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第四章 安装和配置路由 - get请求

在 Koa 中,koa-router 是一个轻量级的路由中间件,它可以帮助你定义路由、处理 HTTP 请求并解析请求参数。通过使用 koa-router,你可以创建一个灵活的路由系统,轻松地组织和管理 Koa 应用的各个部分。

安装 koa-router

首先,你需要安装 koa-router

npm install @koa/router       # 注意:新版 koa-router 包名是 @koa/router
import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router() //实例化一个 Router 对象

//------ get请求
//路由是根据客户端发送的请求(包括请求的路径、方法等)调用与之匹配的处理函数
//根路由 http://127.0.0.1:8008/
router.get('/', async ctx => { //get请求
    ctx.body = "dengruicode.com"
})

//查询参数 http://127.0.0.1:8008/test?id=001&web=dengruicode.com
router.get('/test', async ctx => { //get请求
    let id = ctx.query.id
    let web = ctx.query.web
    ctx.body = id + " : " + web
})

//路径参数 http://127.0.0.1:8008/test2/id/002/web/www.dengruicode.com
router.get('/test2/id/:id/web/:web', async ctx => {
    let id = ctx.params.id
    let web = ctx.params.web
    ctx.body = id + " : " + web
})

//重定向路由 http://127.0.0.1:8008/test3
router.redirect('/test3', 'https://www.baidu.com')

app.use(router.routes()) //将定义在 router 对象中的路由规则添加到 app 实例中

//------ 路由分组
//http://127.0.0.1:8008/user/add
//http://127.0.0.1:8008/user/del

const userRouter = new Router({ prefix: '/user' })
userRouter.get('/add', async ctx => {
    ctx.body = "添加用户"
})
userRouter.get('/del', async ctx => {
    ctx.body = "删除用户"
})
app.use(userRouter.routes())

// 在所有路由之后添加404处理函数
app.use(async ctx => {
    if (!ctx.body) { //若没有设置 ctx.body, 则说明没有到匹配任何路由
        ctx.status = 404
        ctx.body = '404 Not Found'
    }
})

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第五章 post请求

安装 koa-body

Koa 原生不支持解析 POST 请求体,需安装 koa-body 中间件:

npm install koa-body

POST 请求处理示例

修改 src/index.js,新增 POST 路由:

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';

const app = new Koa();
const router = new Router();
const port = 8008;

// 注册 koa-body 中间件:解析 JSON、表单、文件类型的 POST 数据
app.use(koaBody({
  multipart: true, // 支持文件上传(后续第八章用)
  json: true, // 解析 JSON 格式
  urlencoded: true // 解析表单格式(application/x-www-form-urlencoded)
}));

// 1. 处理 JSON 格式 POST 请求
router.post('/api/json', async (ctx) => {
  const { name, age } = ctx.request.body;
  ctx.body = {       // ctx.request.body 是 koa-body 解析后的 POST 数据
    code: 200,
    msg: "JSON 数据接收成功",
    data: { name, age }
  };
});

// 2. 处理表单格式 POST 请求
router.post('/api/form', async (ctx) => {
  const { username, password } = ctx.request.body;
  ctx.body = {
    code: 200,
    msg: "表单数据接收成功",
    data: { username, password }
  };
});

app.use(router.routes());

// 404 处理
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = '404 Not Found';
});

app.listen(port, () => {
  console.log(`POST 服务器启动:http://localhost:${port}`);
});

测试 POST 请求(两种方式)

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/json

  • 请求方法:POST

  • 请求体:选择 raw > JSON,输入:

    { "name": "张三", "age": 20 }
    
  • 响应:{"code":200,"msg":"JSON 数据接收成功","data":{"name":"张三","age":20}}

方式 2:curl 命令测试

# 测试 JSON 格式
curl -X POST -H "Content-Type: application/json" -d '{"name":"张三","age":20}' http://localhost:8008/api/json

# 测试表单格式
curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/form

第六章 错误处理

import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router()

//http://127.0.0.1:8008/
router.get('/', async ctx => {
    throw new Error("测试")
})

/*
    将 '错误处理中间件' 放在 '路由处理中间件' 之前, 当一个请求到达时,
    会先经过 '错误处理中间件', 然后才会进入 '路由处理中间件',
    是为了确保可以捕获错误
*/
app.use(async (ctx, next) => {  // 错误处理中间件
    try {
        await next()
    } catch (err) {
        //console.log('err:', err)
        ctx.status = 500
        ctx.body = 'err: ' + err.message
    }
})

app.use(router.routes())   // 路由处理中间件

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第七章 允许跨域请求

安装跨域中间件

npm install @koa/cors

跨域配置示例

import Koa from 'koa';
import Router from '@koa/router';
import Cors from '@koa/cors';

const app = new Koa();
const router = new Router();
const port = 8008;

app.use(Cors()) //允许跨域请求

// 测试跨域路由
router.get('/api/cors', async (ctx) => {
  ctx.body = {
    code: 200,
    msg: "跨域请求成功"
  };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`跨域服务器启动:http://localhost:${port}`);
});

测试跨域

在任意前端项目(如 Vue / React / HTML 文件)中发送请求:

// 前端代码示例
fetch('http://localhost:8008/api/cors')
  .then(res => res.json())
  .then(data => console.log(data)) // 输出 {code:200, msg:"跨域请求成功"}
  .catch(err => console.error(err));

无跨域报错即配置成功。

第八章 上传图片

依赖准备(复用 koa-body)

koa-body 已支持文件上传,无需额外安装依赖,只需确保配置 multipart: true

图片上传示例

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';
import fs from 'fs';
import path from 'path';

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 创建上传目录(不存在则创建)
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 2. 配置 koa-body 支持文件上传
app.use(koaBody({
  multipart: true, // 开启文件上传
  formidable: {
    uploadDir: uploadDir, // 临时存储目录
    keepExtensions: true, // 保留文件扩展名(如 .png/.jpg)
    maxFieldsSize: 2 * 1024 * 1024, // 限制文件大小 2MB
    filename: (name, ext, part, form) => {
      // 自定义文件名:时间戳 + 原扩展名,避免重复
      return Date.now() + ext;
    }
  }
}));

// 3. 图片上传接口
router.post('/api/upload', async (ctx) => {
  // ctx.request.files 是上传的文件对象
  const file = ctx.request.files.file; // 前端上传的文件字段名需为 file
  if (!file) {
    ctx.status = 400;
    ctx.body = { code: 400, msg: "请选择上传的图片" };
    return;
  }

  // 返回文件信息
  ctx.body = {
    code: 200,
    msg: "图片上传成功",
    data: {
      filename: file.newFilename, // 自定义后的文件名
      path: `/uploads/${file.newFilename}`, // 访问路径
      size: file.size // 文件大小(字节)
    }
  };
});

// 4. 静态文件访问:让上传的图片可通过 URL 访问
app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/uploads/')) {
    const filePath = path.join(uploadDir, ctx.path.replace('/uploads/', ''));
    if (fs.existsSync(filePath)) {
      ctx.type = path.extname(filePath).slice(1); // 设置响应类型(如 png/jpg)
      ctx.body = fs.createReadStream(filePath); // 读取文件并返回
      return;
    }
    ctx.status = 404;
    ctx.body = "文件不存在";
    return;
  }
  await next();
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`图片上传服务器启动:http://localhost:${port}`);
});

测试图片上传

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/upload
  • 请求方法:POST
  • 请求体:选择 form-data,Key 为 file,Type 选 File,上传一张图片。
  • 响应:返回文件路径,如 http://localhost:8008/uploads/1738987654321.png,访问该 URL 可查看图片。

方式 2:curl 命令测试

终端输入 bash 命令

curl -X POST -F "file=@/你的图片路径/xxx.png" http://localhost:8008/api/upload

第九章 cookie

Cookie 是存储在客户端浏览器的小型文本数据,Koa 内置 ctx.cookies API 可以操作 Cookie。

Cookie 操作示例

import Koa from 'koa'
import Router from '@koa/router'
 
const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 设置 Cookie
router.get('/cookie/set', async (ctx) => {
  // ctx.cookies.set(名称, 值, 配置)
  ctx.cookies.set(
    'username', 
    encodeURIComponent('张三'), 
    {
      maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天(毫秒)
      httpOnly: true, // 仅允许服务端访问,防止 XSS 攻击
      secure: false, // 开发环境设为 false(HTTPS 环境设为 true)
      path: '/', // 生效路径(/ 表示全站)
      sameSite: 'lax' // 防止 CSRF 攻击
    }
  );
  ctx.body = { code: 200, msg: "Cookie 设置成功" };
});

// 2. 获取 Cookie
router.get('/cookie/get', async (ctx) => {
  const username = ctx.cookies.get('username');
  ctx.body = {
    code: 200,
    msg: "Cookie 获取成功",
    data: { username }
  };
});

// 3. 删除 Cookie
router.get('/cookie/delete', async (ctx) => {
  ctx.cookies.set('username', '', { maxAge: 0 }); // 设置 maxAge 为 0 即删除
  ctx.body = { code: 200, msg: "Cookie 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Cookie 服务器启动:http://localhost:${port}`);
});

测试 Cookie

  1. 访问 http://localhost:8008/cookie/set → 设置 Cookie;
  2. 访问 http://localhost:8008/cookie/get → 获取 Cookie,输出 {username: "张三"}
  3. 访问 http://localhost:8008/cookie/delete → 删除 Cookie,再次获取则为 undefined

第十章 session

安装 Session 中间件

Koa 原生不支持 Session,需安装 koa-session

npm install koa-session

Session 配置示例

import Koa from 'koa'
import Router from '@koa/router'
import session  from 'koa-session'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 配置 Session 密钥(生产环境需改为随机字符串)
app.keys = ['dengruicode_secret_key'];

// 2. Session 配置
const CONFIG = {
  key: 'koa:sess', // Session Cookie 名称
  maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天
  autoCommit: true,
  overwrite: true,
  httpOnly: true, // 仅服务端访问
  signed: true, // 签名 Cookie,防止篡改
  rolling: false, // 不刷新过期时间
  renew: false, // 快过期时自动续期
  secure: false, // 开发环境 false
  sameSite: 'lax'
};

// 3. 注册 Session 中间件
app.use(session(CONFIG, app));

// 4. Session 操作
// 设置 Session
router.get('/session/set', async (ctx) => {
  ctx.session.user = {
    id: 1,
    name: "张三",
    age: 20
  };
  ctx.body = { code: 200, msg: "Session 设置成功" };
});

// 获取 Session
router.get('/session/get', async (ctx) => {
  const user = ctx.session.user;
  ctx.body = {
    code: 200,
    msg: "Session 获取成功",
    data: { user }
  };
});

// 删除 Session
router.get('/session/delete', async (ctx) => {
  ctx.session = null; // 清空 Session
  ctx.body = { code: 200, msg: "Session 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Session 服务器启动:http://localhost:${port}`);
});

测试 Session

  1. 访问 http://localhost:8008/session/set → 设置 Session;
  2. 访问 http://localhost:8008/session/get → 获取 Session,输出用户信息;
  3. 访问 http://localhost:8008/session/delete → 清空 Session,再次获取则为 undefined

注意:koa-session 是基于 Cookie 的内存 Session,生产环境建议使用 koa-redis 将 Session 存储到 Redis,避免服务重启丢失数据。

第十一章 jwt

安装 JWT 依赖

npm install jsonwebtoken koa-jwt
  • jsonwebtoken:生成 / 解析 JWT 令牌;
  • koa-jwt:验证 JWT 令牌的中间件。

JWT 完整示例

import Koa from 'koa'
import Router from '@koa/router'
import jwt  from 'jsonwebtoken'
import koaJwt  from 'koa-jwt'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. JWT 密钥(生产环境需加密存储)
const JWT_SECRET = 'dengruicode_jwt_secret';
// JWT 过期时间:1 小时(秒)
const JWT_EXPIRES_IN = 3600;

// 2. 登录接口:生成 JWT 令牌
router.post('/api/login', async (ctx) => {
  // 模拟验证用户名密码(生产环境需查数据库)
  const { username, password } = ctx.request.body;
  if (username === 'admin' && password === '123456') {
    // 生成 JWT 令牌
    const token = jwt.sign(
      { id: 1, username }, // 载荷:存储用户信息(不要存敏感数据)
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );
    ctx.body = {
      code: 200,
      msg: "登录成功",
      data: { token }
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 401, msg: "用户名或密码错误" };
  }
});

// 3. 受保护的接口:需要 JWT 验证
// koa-jwt 中间件会自动解析 Authorization 头中的 token
app.use(koaJwt({ secret: JWT_SECRET }).unless({
  path: [/^/api/login/] // 排除登录接口,无需验证
}));

// 4. 获取用户信息接口(需验证 JWT)
router.get('/api/user/info', async (ctx) => {
  // ctx.state.user 是 koa-jwt 解析后的 JWT 载荷
  const { id, username } = ctx.state.user;
  ctx.body = {
    code: 200,
    msg: "获取用户信息成功",
    data: { id, username }
  };
});

app.use(router.routes());

// 5. JWT 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = { code: 401, msg: "token 无效或过期" };
    } else {
      throw err;
    }
  }
});

app.listen(port, () => {
  console.log(`JWT 服务器启动:http://localhost:${port}`);
});

测试 JWT

步骤 1:登录获取 token

curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/login
# 响应:{"code":200,"msg":"登录成功","data":{"token":"xxx.xxx.xxx"}}

步骤 2:携带 token 访问受保护接口

curl -H "Authorization: Bearer 你的token" http://localhost:8008/api/user/info
# 响应:{"code":200,"msg":"获取用户信息成功","data":{"id":1,"username":"admin"}}

步骤 3:token 无效 / 过期测试

携带错误 token 或过期 token 访问,会返回 {"code":401,"msg":"token 无效或过期"}

总结

  1. 核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;

  2. 关键依赖@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT);

  3. 生产建议

    • Session/JWT 密钥需随机生成并加密存储;

    • 文件上传需限制大小和类型,防止恶意上传;

    • 跨域需指定具体域名,而非 *

    • JWT 载荷不要存敏感数据,过期时间不宜过长。

❌
❌