普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月22日掘金 前端
昨天 — 2026年1月21日掘金 前端

手把手实现链表:单链表与双链表的完整实现

作者 颜酱
2026年1月21日 18:26
手把手实现链表:单链表与双链表的完整实现 链表是数据结构的基础,也是面试高频考点。很多初学者会卡在“指针操作混乱”“边界条件处理不当”等问题上。本文将从设计思路出发,拆解单链表实现的核心逻辑,同时补充

大文档也能丝滑流式渲染?试试Markstream-Vue,体验前所未有的Markdown流畅感!

作者 Simon_He
2026年1月21日 18:13

为什么选择Markstream-Vue?只因它“流”得够快!

  • 🚀 真·流式渲染,支持超大文档、实时预览,边写边看不卡顿
  • 🏆 彻底解决传统Markdown组件渲染慢、卡顿、内存暴涨等痛点
  • 🧩 组件化设计,Vue 3项目即插即用,API极简
  • 🎨 支持代码高亮、公式、流程图等丰富内容,体验无缝流畅
  • 🔥 SSR/静态站点/移动端全兼容,性能拉满

真实场景,极致体验

  • 技术文档、知识库、长篇小说、实时协作编辑器……再大的内容都能流畅渲染
  • 支持内容“边加载边渲染”,让用户体验“所见即所得”的极致流畅

3步上手,流式体验立享

  1. 安装依赖
pnpm add markstream-vue
  1. 引入组件
<Markstream :source="longMarkdown" stream />
  1. 享受流式渲染的丝滑体验!

你的Star,是我持续优化的最大动力!

👉 GitHub地址


一杯茶时间,带你用 RWKV 并发模型做 VS Code 多候选代码补全插件 🤔🤔🤔

作者 Moment
2026年1月21日 18:05

在写这份实现教程之前,我已经把该插件的一个版本发布到了 VS Code 扩展市场,在市场中搜索 rwkv 即可找到,你可以先安装试用,再决定是否跟着下文从零实现一版。

本文以这款基于 RWKV 模型的智能代码补全插件为例,讲解从零实现 VS Code 扩展的思路与步骤,并说明如何接入 rwkv_lightning 后端。

该插件通过并发推理一次返回多个不同的补全答案供选择,在侧边栏展示,方便在多种写法之间对比、挑选后再插入,适合写一半、让模型多想几种实现的编码方式;光标后有代码时自动走 FIM(Fill-in-the-Middle)接口做中间填充,否则走普通续写。全文按功能目标、代码实现(项目结构、补全触发、API 调用、Webview 展示与插入)、后端接入组织,后端部分包含硬件要求、模型准备、与 Albatross 的关系、启动服务、模型加载机制、HTTP API、快速测试以及插件配置与验证,文末附常见问题。

下图为在编辑器中触发补全后,并发推理得到的多个不同答案在侧边栏展示、点击即可插入到光标位置的情形。

rwkv-code-completion 效果

前端项目地址:rwkv-code-completion

后端项目地址:rwkv_lightning

一、我们要做怎样的功能

动手写代码之前,首先要考虑我们要实现一个什么样的 VS Code 插件,这决定了后续的架构与实现方式。

在本例中,我们想做一款智能代码补全插件,并事先想清楚四件事。补全结果通过并发推理一次返回多个不同的答案,在侧边栏展示供用户选择,点选后插入。根据光标后是否已有代码,在 FIM(Fill-in-the-Middle)与普通续写之间自动切换接口。在空格、换行、删除等操作时自动触发,并做好防抖与取消,避免频繁请求。服务地址、密码、生成长度、采样参数(temperaturetop_p)、候选数量、防抖延迟等通过 VS Code 设置暴露。把这四件事的对应关系梳理出来,大致如下:

20260121175047

把这些想清楚之后,再按代码实现过程和如何接入后端两部分往下做。

二、代码实现过程

2.1 项目结构

yo code 或手工 scaffold 一个扩展后,核心只需两个源码文件,职责分开,与 VS Code 打交道的放一边,与后端 HTTP 打交道的放另一边,方便维护和单测。

  • src/extension.ts 作为插件入口,在 activate 里实现 CompletionItemProvider、注册补全、用 onDidChangeTextDocument 监听编辑并按条件触发补全;拿到候选列表后,不再往原生 suggest 里塞,而是创建 Webview、渲染多条结果,并处理用户点击插入与插完再补全。
  • src/completionService.ts 负责补全服务,根据有无 suffix 选择调用普通续写接口或 FIM 接口,组装请求体、发 fetch、解析 data.choicesstring[],并透传 AbortSignal 以支持取消。

两者与后端的关系可以概括为:

20260121175344

package.json 里,main 指向打包后的入口(如 ./dist/extension.js),VS Code 按它加载扩展;activationEvents 可设为 onStartupFinished,这样只在 IDE 就绪后才激活,避免启动时卡顿;contributes.configuration 声明 enabledbaseUrlpasswordmaxTokenstemperaturetopPnumChoicesdebounceDelay 等,用户改设置后可通过 vscode.workspace.getConfiguration("rwkv-code-completion") 读到。

构建可用 esbuild 或 tsc,把 extension.ts 等打出到 dist,调试和发布都从 dist 走。

2.2 激活与补全触发

激活时在 activate(context) 里完成两件事,一是向 VS Code 注册谁在什么情况下提供补全,二是监听文档变更,在特定编辑动作后自动调出补全,用户不必每次手动按 Ctrl+Space。

实现 vscode.CompletionItemProviderprovideCompletionItems(document, position, token, context),再用 vscode.languages.registerCompletionItemProvider 挂上去。selector{ pattern: "**" } 表示对所有语言生效;第三参数 triggerChars 是一串字符,当用户输入或删除其中某一个时,VS Code 会来调 provideCompletionItems。这里把空格、换行以及 ASCII 33–126(常见可打印字符)都放进去了,这样在写代码、加空格、换行时都有机会触发,例如:

const selector = { pattern: "**" };
const triggerChars = [
  " ",
  "\n",
  ...Array.from({ length: 94 }, (_, i) => String.fromCharCode(i + 33)),
];
vscode.languages.registerCompletionItemProvider(
  selector,
  provider,
  ...triggerChars,
);

光有 triggerChars 还不够,例如用户输入 abc 时也会触发,容易导致敲一个字母就发一次请求。因此再加一层文档变更的过滤,用 vscode.workspace.onDidChangeTextDocument 监听,只有在本次编辑是删除、换行或输入一个空格时,才在防抖后执行 editor.action.triggerSuggest,从而间接调用 provideCompletionItems。这样可以把触发收敛到更自然的断句、换行场景,例如:

const shouldTrigger = event.contentChanges.some((change) => {
  const isDelete = change.rangeLength > 0 && change.text === "";
  const isNewline = change.text === "\n" || change.text === "\r\n";
  const isSpace = change.text === " ";
  return isDelete || isNewline || isSpace;
});
if (shouldTrigger) {
  debounceTimer = setTimeout(() => {
    vscode.commands.executeCommand("editor.action.triggerSuggest");
  }, config.debounceDelay);
}

防抖时间用 config.debounceDelay(如 150–300ms),用户停一会儿才发请求,减少连打时的无效调用。还可以加两条限制,一是只处理当前活动编辑器的文档,避免在切文件、分屏时误触发,二是与上一次触发至少间隔几百毫秒,进一步避免短时间内重复弹补全。整体触发链路如下:

20260121175403

2.3 补全逻辑与 API 调用

provideCompletionItems 被调用后,先做一轮要不要真的发请求的过滤和节流,再取上下文、调后端、拿 string[]

流程可以拆成五步。一,读配置,若 enabled 为 false 直接 return null。二,防抖,用 setTimeout(..., debounceDelay) 把实际请求放到回调里;若在等待期间又有新的触发,则 clearTimeout 掉上一次,只保留最后一次,这样连续输入时只会发一次请求。三,若此前已有进行中的 fetch,用 AbortController.abort() 取消,再 new AbortController() 给本次请求用。四,取上下文,前缀 prefix 为从文档开头到光标前的文本,document.getText(new vscode.Range(0, 0, position)),过长时截断到约 2000 字符,避免超过后端限制;后缀 suffix 为从光标到往后若干行(如 10 行),主要用来判断光标后是否还有代码,从而决定走 FIM 还是普通续写。五,调用 CompletionService.getCompletion(prefix, suffix, languageId, config, abortController.signal),在 withProgress 里展示正在生成 N 个补全并可取消。五步关系如下:

20260121175421

CompletionService.getCompletion 内部按 suffix 是否非空分支,有后缀则认为用户在中间写代码,走 FIM,否则走普通续写。接口选择如下:

20260121175704

例如下面这样。

async getCompletion(prefix, suffix, languageId, config, signal): Promise<string[]> {
  const hasSuffix = suffix && suffix.trim().length > 0;
  return hasSuffix
    ? this.callFIMAPI(prefix, suffix, config, signal)
    : this.callCompletionAPI(prefix, config, signal);
}

普通补全走 callCompletionAPI,请求 POST {baseUrl}/v2/chat/completions。body 里 contentsArray(numChoices).fill(prefix),即同一段 prefix 复制多份,利用后端批量接口一次推理出多条不同采样结果;再配上 stream: falsepasswordmax_tokenstemperaturetop_pstop_tokens 等。返回的 data.choices 里,每条取 choice.message?.content || choice.text,trim 掉首尾空白并滤掉空串,得到 string[]

FIM 补全走 callFIMAPI,请求 POST {baseUrl}/FIM/v1/batch-FIMprefixsuffix 各为长度为 4 的数组(同一 prefix、同一 suffix 各复制 4 份),对应 4 条并发中间填充;其它参数与普通补全类似,解析方式相同。两处都把 signal 传给 fetch,这样在用户点击取消、或防抖导致下一次触发而 abort() 时,正在进行的请求会被中断,不把过时结果再展示出来。

2.4 Webview 展示与插入

拿到 string[] 之后,不转成 CompletionItem[] 通过 resolve(items) 塞给原生 suggest,因为原生列表单条、偏短,且没法做多列、点击选一等自定义交互。这里改为 resolve(null) 表示不往建议列表里填,同时在 withProgress 里调 showCompletionWebview(document, position, completions, languageId),用 Webview 在侧边栏展示多条候选,支持多选一、点即插、插完再补。

vscode.window.createWebviewPanel 创建 Webview,指定 id、标题、ViewColumn.Two 在侧边打开,以及 enableScripts: trueretainContextWhenHidden: true 以便跑脚本和在切走时保留状态。panel.webview.htmlgetWebviewContent(completions, languageId) 生成。在打开面板之前,必须把当时的 documentposition 存到闭包或变量里,因为 Webview 是异步的,用户可能切文件、移光标,等到点击插入时要以当初触发补全的那次位置为准,否则会插错地方。

const panel = vscode.window.createWebviewPanel(
  "rwkvCompletion",
  "RWKV 代码补全 (N 个选项)",
  vscode.ViewColumn.Two,
  { enableScripts: true, retainContextWhenHidden: true },
);
panel.webview.html = getWebviewContent(completions, languageId);

HTML 里顶部放标题与简短说明,下面一个 div 容器,用 grid-template-columns: 1fr 1fr 做多列布局,每个格子一个 div.code-block,含小标题(序号、字符数、行数)和 <pre><code> 放补全内容。补全文本要先做 HTML 转义再插入,避免 XSS;颜色、背景用 var(--vscode-editor-background) 等,跟主题一致;:hover.selected 给一点高亮,点的时候有反馈。

前端通过 acquireVsCodeApi() 拿到和扩展通信的 API,completionsgetWebviewContent 里用 JSON 注入到页面。每个 code-block 点击时执行 vscode.postMessage({ command: 'insert', code: completions[index] })。扩展侧在 panel.webview.onDidReceiveMessage 里监听,若 message.command === 'insert',先 vscode.window.showTextDocument(targetDocument, ViewColumn.One) 把原文档激活到主编辑区,再用 editor.edit(eb => eb.insert(targetPosition, message.code)) 在事先存好的 targetPosition 插入;插入成功后 panel.dispose() 关掉 Webview,并 setTimeout(..., 300) 后执行 editor.action.triggerSuggest,让光标后的新内容再触发一轮补全,形成补全、选一、再补全的连贯体验。从拿到结果到插入再触发的流程如下:

20260121175751

原生 suggest 只能一条条、样式固定,没法同时展示多条并发结果和自定义交互;用 Webview 可以自己布局、自己处理点击和插入,更适合并发推理、多答案选一的用法。

三、如何接入后端

插件通过 HTTP 调用 rwkv_lightning,需要先部署后端,再在 VS Code 里填好配置。扩展详情页会标注后端部署与配置说明,便于快速上手,下图为扩展市场中的页面示意。

RWKV 代码补全 - 扩展市场页面

接入后端的整体步骤如下。

20260121175818

3.1 硬件要求

重要提示:本后端必须使用 GPU 加速,不支持纯 CPU 运行。

rwkv_lightning 依赖自定义的 CUDA 或 HIP 内核进行高性能推理,因此需要以下硬件之一:

  • NVIDIA GPU:需要支持 CUDA 的 NVIDIA 显卡,并安装 CUDA 工具包
  • AMD GPU:需要支持 ROCm 的 AMD 显卡,并安装 ROCm 运行时

如果您只有 CPU 环境,请使用 llama.cpp 进行 RWKV 模型的 CPU 推理,该项目针对 CPU 进行了专门优化。

3.2 模型文件准备

rwkv_lightning 当前不提供自动下载功能,需要您自行准备模型权重文件。

下载模型权重

RWKV-7 模型的官方权重托管在 Hugging Face 上,推荐从 BlinkDL/rwkv7-g1 仓库下载。模型文件格式为 .pth,例如 rwkv7-g1b-1.5b-20251202-ctx8192.pth

您可以通过以下方式下载:

方式一:使用 huggingface-cli(推荐)

# 首先登录 Hugging Face(如未登录)
huggingface-cli login

# 下载模型文件
huggingface-cli download BlinkDL/rwkv7-g1 \
  rwkv7-g1b-1.5b-20251202-ctx8192.pth \
  --local-dir /path/to/models \
  --local-dir-use-symlinks False

方式二:使用 Python 脚本

from huggingface_hub import hf_hub_download

model_path = hf_hub_download(
    repo_id="BlinkDL/rwkv7-g1",
    filename="rwkv7-g1b-1.5b-20251202-ctx8192.pth",
    local_dir="/path/to/models"
)
print(f"模型已下载到: {model_path}")

路径命名规则

启动服务时,--model-path 支持两种写法。写法一:不带后缀,程序会自动补上 .pth,例如:

--model-path /path/to/rwkv7-g1b-1.5b-20251202-ctx8192
# 实际加载: /path/to/rwkv7-g1b-1.5b-20251202-ctx8192.pth

3.3 与 Albatross 的关系

rwkv_lightning 是基于 Albatross 高效推理引擎开发的 HTTP 服务后端。Albatross 是 BlinkDL 开发的高性能 RWKV 推理引擎,专注于底层计算优化和性能基准测试。

Albatross 项目简介

Albatross 是一个独立的开源项目,GitHub 地址:github.com/BlinkDL/Alb… RWKV-7 模型的高效推理实现,包括:

  • 批量推理支持:支持大规模批量处理,在 RTX 5090 上可实现 7B 模型 fp16 bsz960 超过 10000 token/s 的解码速度
  • 性能优化:集成了 CUDA Graph、稀疏 FFN、自定义 CUDA 内核等优化技术
  • 基准测试工具:提供详细的性能基准测试脚本,用于评估不同配置下的推理性能
  • 参考实现:包含完整的模型实现和工具类,可作为开发参考

性能参考数据

根据 Albatross 官方测试结果(RTX 5090,RWKV-7 7.2B fp16):

  • 单样本解码(bsz=1):145+ token/s,使用 CUDA Graph 优化后可达 123+ token/s
  • 批量解码(bsz=960):10250+ token/s
  • Prefill 阶段(bsz=1):11289 token/s
  • 批量解码(bsz=320):5848 token/s,速度恒定且显存占用稳定(RNN 特性)

rwkv_lightning 的定位

rwkv_lightning 在 Albatross 的基础上,专注于提供生产级的 HTTP 推理服务:

  • HTTP API 接口:提供完整的 RESTful API,支持流式和非流式推理
  • 状态管理:实现三级缓存系统(VRAM、RAM、Disk),支持会话状态持久化
  • 连续批处理:动态管理批次,提高 GPU 利用率
  • 多接口支持:提供聊天、翻译、代码补全等多种应用场景的专用接口

如果您需要深入了解底层实现细节、进行性能调优或对比不同优化方案,建议参考 Albatross 项目的源代码和基准测试脚本。Albatross 提供了更底层的实现细节,而 rwkv_lightning 则专注于提供易用的服务化接口。

3.4 启动推理服务

rwkv_lightning 以 Robyn 版本为主,提供密码认证、多接口、状态管理等特性,适合生产环境使用。Robyn 版本功能更全面,支持密码认证、多接口、状态管理等高级特性,适合生产环境使用。

python main_robyn.py --model-path /path/to/model --port 8000 --password rwkv7_7.2b

如果不需要密码保护,可以省略 --password 参数:

python main_robyn.py --model-path /path/to/model --port 8000

3.5 模型加载机制

了解模型加载机制有助于排查问题和优化性能。

权重加载流程

模型类 RWKV_x070 在初始化时会执行以下步骤:

  1. 读取权重文件:使用 torch.load(args.MODEL_NAME + '.pth', map_location='cpu') 将权重加载到 CPU 内存
  2. 数据类型转换:将权重转换为半精度(dtype=torch.half)以节省显存
  3. 设备迁移:根据硬件平台将权重移动到 GPU
    • NVIDIA GPU:使用 device="cuda"
    • AMD GPU:使用 ROCm 的 HIP 运行时

词表加载

词表文件 rwkv_batch/rwkv_vocab_v20230424.txt 通过 TRIE_TOKENIZER 类自动加载。TRIE 数据结构提供了高效的 token 查找和编码、解码功能。

CUDA、HIP 内核编译

项目包含自定义的 CUDA(NVIDIA)和 HIP(AMD)内核,用于加速 RWKV 的核心计算。这些内核在首次导入 rwkv_batch.rwkv7 模块时通过 torch.utils.cpp_extension.load 自动编译和加载:

  • CUDA 内核:rwkv_batch/cuda/rwkv7_state_fwd_fp16.cu
  • HIP 内核:rwkv_batch/hip/rwkv7_state_fwd_fp16.hip

首次运行时会进行编译,可能需要几分钟时间。编译后的内核会被缓存,后续启动会更快。

3.6 HTTP API 接口

rwkv_lightning 提供了丰富的 HTTP API 接口,支持多种推理场景。

聊天完成接口

  • v1/chat/completions:基础批量同步处理接口,支持流式和非流式输出。
  • v2/chat/completions:连续批处理接口,动态管理批次以提高 GPU 利用率,适合高并发场景。
  • v3/chat/completions:异步批处理接口,使用 CUDA Graph 优化(batch_size=1 时),提供最低延迟。

Fill-in-the-Middle 接口

FIM/v1/batch-FIM:支持代码和文本的中间填充补全,适用于代码补全、文本编辑等场景。

批量翻译接口

translate/v1/batch-translate:批量翻译接口,兼容沉浸式翻译插件的 API 格式,支持多语言互译。

会话状态管理接口

state/chat/completions:支持会话状态缓存的对话接口,实现多轮对话的上下文保持。状态采用三级缓存设计:

  • L1 缓存:VRAM(显存),最快访问
  • L2 缓存:RAM(内存),中等速度
  • L3 缓存:SQLite 数据库(磁盘),持久化存储

流式推理示例

以下示例展示如何使用 v2 接口进行批量流式推理:

curl -N -X POST http://localhost:8000/v2/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [
      "English: After a blissful two weeks, Jane encounters Rochester in the gardens.\n\nChinese:",
      "English: That night, a bolt of lightning splits the same chestnut tree.\n\nChinese:"
    ],
    "max_tokens": 1024,
    "stop_tokens": [0, 261, 24281],
    "temperature": 0.8,
    "top_k": 50,
    "top_p": 0.6,
    "alpha_presence": 1.0,
    "alpha_frequency": 0.1,
    "alpha_decay": 0.99,
    "stream": true,
    "chunk_size": 128,
    "password": "rwkv7_7.2b"
  }'

3.7 快速测试与性能评估

快速测试

项目提供了测试脚本,可以快速验证服务是否正常运行:

bash ./test_curl.sh

该脚本会发送示例请求到本地服务,检查各个接口的基本功能。

性能基准测试

使用 benchmark.py 脚本可以评估模型的推理性能,包括吞吐量、延迟等指标:

# 需要先修改 benchmark.py 中的模型路径
python benchmark.py

基准测试会输出详细的性能报告,帮助您了解模型在不同配置下的表现。

3.8 插件配置

在 VS Code 中打开设置(可搜索 rwkv-code-completion 或执行命令 RWKV: 打开设置),重点配置:

配置项 说明 示例
rwkv-code-completion.enabled 是否启用补全 true
rwkv-code-completion.baseUrl 后端基础地址,不含路径 http://192.168.0.157:8000http://localhost:8000
rwkv-code-completion.password --password 一致 rwkv7_7.2b
rwkv-code-completion.maxTokens 单次生成最大 token 数 200
rwkv-code-completion.numChoices 普通补全的候选数量(1–50) 24
rwkv-code-completion.debounceDelay 防抖延迟(毫秒) 150300

baseUrl 只需填 http(s)://host:port,插件内部会拼上 /v2/chat/completions/FIM/v1/batch-FIM。若设置界面中仅有 endpoint 等项,可在 settings.json 中手动添加 "rwkv-code-completion.baseUrl": "http://<主机>:<端口>"

3.9 验证接入

可先用 curl -X POST http://<host>:<port>/v2/chat/completions -H "Content-Type: application/json" -d '{"contents":["你好"],"max_tokens":10,"password":"<你的password>"}' 或运行 ./test_curl.sh 确认 v2 与 FIM 接口正常。在任意代码文件中输入、换行或删除,防抖后应出现「🤖 RWKV 正在生成 N 个代码补全...」并弹出侧边栏展示多个候选;若失败,可查看「输出」中该扩展的 channel 或弹窗报错,检查 baseUrlpassword、端口与防火墙。


四、常见问题

为何不能在 CPU 上运行?

rwkv_lightning 的核心计算依赖自定义的 CUDA、HIP 内核,这些内核专门为 GPU 并行计算设计。CPU 无法执行这些内核代码,因此必须使用 GPU。如果您需要在 CPU 上运行 RWKV 模型,请使用 llama.cpp,它提供了针对 CPU 优化的实现。

模型权重应该放在哪里?

模型权重可以放在任何可访问的路径。启动服务时通过 --model-path 参数指定路径即可。路径可以是绝对路径或相对路径,程序会自动处理 .pth 后缀的添加。

首次启动为什么很慢?

首次启动时会编译 CUDA、HIP 内核,这个过程可能需要几分钟。编译后的内核会被缓存,后续启动会快很多。如果希望进一步优化性能,可以考虑使用 torch.compile 模式(详见 README.md 中的 Tips 部分)。

如何选择合适的接口?

  • v1:适合简单的批量推理需求
  • v2:适合高并发场景,需要动态批处理
  • v3:适合单请求低延迟场景(batch_size=1)
  • FIM:适合代码补全和文本编辑
  • state:适合需要保持上下文的对话场景

本插件已按「无 suffix 用 v2、有 suffix 用 FIM」自动选择。

如何实现自动下载模型?

当前版本不提供内置的自动下载功能。您可以在启动脚本中添加下载逻辑,使用 huggingface_hub 库在启动前检查并下载模型文件。

主Agent与多个协同子Agent的方案设计

作者 sorryhc
2026年1月21日 17:51

前言

如今的大模型应用架构设计基本都是一个主Agent携带多个子Agent。

主Agent负责调度其他垂类Agent,子Agent负责单一领域的角色,属于垂直域专家。

架构上比较类似这样:

┌─────────────────────────────────────────────────────────┐
│                    主 Agent(Orchestrator)              │
│  职责:理解用户意图、分解任务、协调子 Agent、聚合结果   │
└──────────────────────┬──────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┬──────────────┐
        │              │              │              │
        ▼              ▼              ▼              ▼
   ┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
   │差旅Agent│   │日程Agent│   │支付Agent│   │通知Agent│
   │(Travel)│   │(Calendar)│  │(Payment)│  │(Alert) │
   └────────┘    └────────┘    └────────┘    └────────┘
        │              │              │              │
        └──────────────┴──────────────┴──────────────┘
                       │
        ┌──────────────┼──────────────┐
        │              │              │
        ▼              ▼              ▼
   数据库           API 服务        外部服务
   (DB)          (Flights,        (Payment,
                  Hotels,          Email,
                 Trains)          SMS)

那一个基本的LLM应用框架一般怎么设计?本文基于Midwayjs来解读分析。

Agent&提示词设计

基类Agent

所有Agent都集成于该类,核心触发如下能力。

  1. 上下文管理;
  2. 大模型调用;
  3. 提示词注入;
// src/agent/base-agent.ts
import { Logger } from "@midwayjs/core"
import { LLMService } from "@/service/llm.service"

interface Message {
  role: "system" | "user" | "assistant"
  content: string
}

interface ToolCall {
  name: string
  arguments: Record<string, any>
  id?: string
}

/**
 * Agent 基类
 * 所有 Agent 都继承这个基类
 */
export abstract class BaseAgent {
  @Logger()
  logger: any

  protected llmService: LLMService
  protected conversationHistory: Message[] = []

  constructor(llmService: LLMService) {
    this.llmService = llmService
  }

  /**
   * 初始化 Agent
   * 1. 设置系统提示词
   * 2. 注入工具定义
   * 3. 初始化对话历史
   */
  protected initializeAgent(
    systemPrompt: string,
    tools: any[]
  ): void {
    this.logger.info(`[${this.getAgentName()}] 初始化 Agent`)

    // Step 1: 清空历史对话
    this.conversationHistory = []

    // Step 2: 添加系统提示词
    const enrichedSystemPrompt = this.enrichSystemPrompt(
      systemPrompt,
      tools
    )

    this.conversationHistory.push({
      role: "system",
      content: enrichedSystemPrompt,
    })

    this.logger.info(
      `[${this.getAgentName()}] Agent 初始化完成,已注入 ${tools.length} 个工具`
    )
  }

  /**
   * 增强系统提示词(注入工具定义)
   */
  private enrichSystemPrompt(systemPrompt: string, tools: any[]): string {
    const toolDescriptions = tools
      .map(
        (tool) => `
### 工具:${tool.name}
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`
      )
      .join("\n")

    return `
${systemPrompt}

## 可用的工具

${toolDescriptions}

## 工具调用格式

当你需要使用工具时,请返回以下 JSON 格式:
\`\`\`json
{
  "type": "tool_call",
  "tool_name": "工具名称",
  "arguments": {
    "参数1": "值1",
    "参数2": "值2"
  }
}
\`\`\`

重要:
1. 每次只调用一个工具
2. 工具会返回结果,你会收到 "tool_result" 角色的消息
3. 根据工具结果继续推理和决策
4. 最终向用户返回友好的文字回复
`
  }

  /**
   * 与大模型交互(核心方法)
   */
  async callLLM(userMessage: string): Promise<string> {
    this.logger.info(
      `[${this.getAgentName()}] 用户消息: ${userMessage}`
    )

    // 1. 添加用户消息到历史
    this.conversationHistory.push({
      role: "user",
      content: userMessage,
    })

    // 2. 调用大模型
    let response = await this.llmService.call({
      model: "gpt-4",
      messages: this.conversationHistory,
      temperature: 0.7,
      maxTokens: 2000,
    })

    this.logger.info(
      `[${this.getAgentName()}] 模型响应: ${response.content.substring(0, 100)}...`
    )

    // 3. 检查是否是工具调用
    let finalResponse = response.content
    let toolCalls = this.extractToolCalls(response.content)

    // 4. 如果有工具调用,递归执行直到没有工具调用
    while (toolCalls.length > 0) {
      this.logger.info(
        `[${this.getAgentName()}] 检测到工具调用: ${toolCalls.map((t) => t.name).join(", ")}`
      )

      // 添加助手的响应到历史
      this.conversationHistory.push({
        role: "assistant",
        content: response.content,
      })

      // 执行所有工具调用
      const toolResults = await Promise.all(
        toolCalls.map((call) =>
          this.executeTool(call.name, call.arguments)
        )
      )

      // 5. 将工具结果添加到历史
      const toolResultMessage = toolResults
        .map(
          (result, index) => `
[工具结果 ${index + 1}]
工具:${toolCalls[index].name}
参数:${JSON.stringify(toolCalls[index].arguments)}
结果:${JSON.stringify(result, null, 2)}
`
        )
        .join("\n")

      this.conversationHistory.push({
        role: "user",
        content: `工具执行结果:\n${toolResultMessage}`,
      })

      this.logger.info(
        `[${this.getAgentName()}] 工具执行完成,继续推理...`
      )

      // 6. 再次调用大模型,让它基于工具结果继续推理
      response = await this.llmService.call({
        model: "gpt-4",
        messages: this.conversationHistory,
        temperature: 0.7,
        maxTokens: 2000,
      })

      this.logger.info(
        `[${this.getAgentName()}] 后续模型响应: ${response.content.substring(0, 100)}...`
      )

      // 7. 再次检查是否有工具调用
      toolCalls = this.extractToolCalls(response.content)
      finalResponse = response.content
    }

    // 8. 添加最终回复到历史
    this.conversationHistory.push({
      role: "assistant",
      content: finalResponse,
    })

    return finalResponse
  }

  /**
   * 提取工具调用(从模型响应中)
   */
  private extractToolCalls(content: string): ToolCall[] {
    const toolCalls: ToolCall[] = []

    // 匹配 JSON 格式的工具调用
    const jsonMatches = content.match(/```json\n([\s\S]*?)\n```/g)

    if (jsonMatches) {
      jsonMatches.forEach((match) => {
        try {
          const json = match.replace(/```json\n/g, "").replace(/\n```/g, "")
          const parsed = JSON.parse(json)

          if (parsed.type === "tool_call") {
            toolCalls.push({
              name: parsed.tool_name,
              arguments: parsed.arguments,
            })
          }
        } catch (error) {
          this.logger.warn(`[${this.getAgentName()}] 无法解析 JSON: ${match}`)
        }
      })
    }

    return toolCalls
  }

  /**
   * 执行工具(由子类实现)
   */
  protected abstract executeTool(
    toolName: string,
    arguments: Record<string, any>
  ): Promise<any>

  /**
   * 获取 Agent 名称
   */
  protected abstract getAgentName(): string
}

工具定义&设计

工具定义核心是基于约定式的配置体,来提供给大模型。

这些工具可以是mcp,可以是function call,在工具中增加type即可扩展。

// src/tools/travel-tools.ts

/**
 * 差旅工具定义
 * 这些工具会被注入到 Agent 的提示词中
 */
export const TRAVEL_TOOLS = [
  {
    name: "search_flights",
    description: "搜索机票,返回可用的航班列表",
    parameters: {
      type: "object",
      properties: {
        from: {
          type: "string",
          description: "出发城市(如:北京、上海)",
        },
        to: {
          type: "string",
          description: "目的城市",
        },
        date: {
          type: "string",
          description: "出发日期(格式:YYYY-MM-DD)",
        },
        return_date: {
          type: "string",
          description: "返回日期(可选,格式:YYYY-MM-DD)",
        },
      },
      required: ["from", "to", "date"],
    },
  },
  {
    name: "search_hotels",
    description: "搜索酒店,返回可用的酒店列表",
    parameters: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "目的城市",
        },
        check_in: {
          type: "string",
          description: "入住日期(格式:YYYY-MM-DD)",
        },
        check_out: {
          type: "string",
          description: "退房日期(格式:YYYY-MM-DD)",
        },
        max_price: {
          type: "number",
          description: "最高价格(可选,单位:元)",
        },
      },
      required: ["city", "check_in", "check_out"],
    },
  },
  {
    name: "book_trip",
    description: "预订机票和酒店,返回订单号",
    parameters: {
      type: "object",
      properties: {
        flight_id: {
          type: "string",
          description: "航班 ID",
        },
        hotel_id: {
          type: "string",
          description: "酒店 ID",
        },
        passengers: {
          type: "number",
          description: "乘客人数",
        },
      },
      required: ["flight_id", "hotel_id"],
    },
  },
  {
    name: "get_trip_details",
    description: "获取已预订差旅的详细信息",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
      },
      required: ["trip_id"],
    },
  },
  {
    name: "cancel_trip",
    description: "取消已预订的差旅",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
        reason: {
          type: "string",
          description: "取消原因(可选)",
        },
      },
      required: ["trip_id"],
    },
  },
]

export const CALENDAR_TOOLS = [
  {
    name: "add_calendar_event",
    description: "添加日历事件",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "事件标题",
        },
        start_date: {
          type: "string",
          description: "开始时间(格式:YYYY-MM-DD HH:mm)",
        },
        end_date: {
          type: "string",
          description: "结束时间(格式:YYYY-MM-DD HH:mm)",
        },
        description: {
          type: "string",
          description: "事件描述",
        },
      },
      required: ["title", "start_date", "end_date"],
    },
  },
  {
    name: "get_calendar_events",
    description: "查询特定日期的日历事件",
    parameters: {
      type: "object",
      properties: {
        date: {
          type: "string",
          description: "查询日期(格式:YYYY-MM-DD)",
        },
      },
      required: ["date"],
    },
  },
]

export const PAYMENT_TOOLS = [
  {
    name: "process_payment",
    description: "处理支付请求",
    parameters: {
      type: "object",
      properties: {
        order_id: {
          type: "string",
          description: "订单号",
        },
        amount: {
          type: "number",
          description: "金额(单位:元)",
        },
        payment_method: {
          type: "string",
          enum: ["credit_card", "debit_card", "wechat", "alipay"],
          description: "支付方式",
        },
      },
      required: ["order_id", "amount", "payment_method"],
    },
  },
]

export const ALERT_TOOLS = [
  {
    name: "send_notification",
    description: "发送通知给用户",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "通知标题",
        },
        content: {
          type: "string",
          description: "通知内容",
        },
        channels: {
          type: "array",
          items: { type: "string", enum: ["email", "sms", "app"] },
          description: "通知渠道",
        },
      },
      required: ["title", "content", "channels"],
    },
  },
]

MCP设计

Agent基于多个Mcp能力的提供从而实现更垂直的领域能力。

因此Mcp也可以单独设计出来。

// src/mcp/types.ts

/**
 * MCP 工具定义
 */
export interface MCPTool {
  name: string
  description: string
  inputSchema: {
    type: "object"
    properties: Record<string, any>
    required: string[]
  }
}

/**
 * MCP 资源定义
 */
export interface MCPResource {
  uri: string
  name: string
  description: string
  mimeType: string
  contents: string
}

/**
 * MCP 提示词定义
 */
export interface MCPPrompt {
  name: string
  description: string
  arguments?: Array<{
    name: string
    description: string
    required?: boolean
  }>
}

/**
 * MCP 工具调用请求
 */
export interface MCPToolCallRequest {
  toolName: string
  arguments: Record<string, any>
}

/**
 * MCP 工具执行结果
 */
export interface MCPToolResult {
  success: boolean
  data?: any
  error?: string
}

/**
 * MCP 服务器接口
 */
export interface IMCPServer {
  // 获取服务器信息
  getServerInfo(): Promise<{
    name: string
    version: string
    capabilities: string[]
  }>

  // 列出所有可用工具
  listTools(): Promise<MCPTool[]>

  // 执行工具
  callTool(request: MCPToolCallRequest): Promise<MCPToolResult>

  // 列出所有可用资源
  listResources(): Promise<MCPResource[]>

  // 获取资源内容
  getResource(uri: string): Promise<MCPResource>

  // 列出所有可用提示词
  listPrompts(): Promise<MCPPrompt[]>

  // 获取提示词内容
  getPrompt(name: string, arguments?: Record<string, string>): Promise<string>
}

有了AgentMcp,本质上完整的一次自然语言对话 -> 反馈的系统流转图就很清晰了。

基于这套框架来扩展即可。

一次完整对话到反馈的时序图大概是这样:

用户                主Agent              子Agent           MCP服务器         LLM模型          数据库
 │                   │                   │                 │                │                │
 │ 用户请求:         │                   │                 │                │                │
 │ "帮我订一张      │                   │                 │                │                │
 │  明天北京到      │                   │                 │                │                │
 │  上海的机票      │                   │                 │                │                │
 │  和酒店"        │                   │                 │                │                │
 │──────────────────>│                   │                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 1. 初始化对话      │                 │                │                │
 │                   │    构建系统提示词  │                 │                │                │
 │                   │────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 2. 请求可用工具列表│                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │                │
 │                   │ 3. 返回工具列表    │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    (search_flights, search_hotels,   │                │                │
 │                   │     book_trip, etc.)                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 4. 获取提示词模板  │                 │                │                │
 │                   │──────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 5. 返回提示词      │                 │                │                │
 │                   │<──────────────────────────────────────│                │                │
 │                   │   (booking_recommendation等)         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 6. 构建系统消息    │                 │                │                │
 │                   │    (系统提示词+工具定义+提示词)      │                │                │
 │                   │    users消息="用户请求内容"         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 7. 调用 LLM        │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析意图       │
 │                   │                   │                 │                │  (BOOK_TRIP)    │
 │                   │                   │                 │                │  提取参数       │
 │                   │                   │                 │                │  (from, to,date)│
 │                   │                   │                 │                │  生成工具调用   │
 │                   │                   │                 │                │                │
 │                   │ 8. LLM 响应        │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_flights",  │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "from": "北京",                │                │                │
 │                   │        "to": "上海",                  │                │                │
 │                   │        "date": "明天"                 │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 9. 检测到工具调用, │                 │                │                │
 │                   │    路由到子Agent   │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 10. 子Agent     │                │                │
 │                   │                   │     处理工具调用 │                │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 11. Travel MCP  │                │                │
 │                   │                   │     执行         │                │                │
 │                   │                   │     search_flights│               │                │
 │                   │                   │                 │ 查询数据库     │                │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回机票列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 12. 返回工具结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    [                                │                │                │ │                   │      {                               │                │                │ │                   │        "id": "CA123",                │                │                │ │                   │        "airline": "国航",             │                │                │ │                   │        "departure": "10:00",         │                │                │ │                   │        "price": 1200                 │                │                │ │                   │      },                              │                │                │ │                   │      ...                             │                │                │ │                   │    ]                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 13. 添加工具结果   │                 │                │                │
 │                   │     到对话历史     │                 │                │                │
 │                   │     再次调用 LLM   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析机票      │
 │                   │                   │                 │                │  生成下一个工具│
 │                   │                   │                 │                │  调用:         │
 │                   │                   │                 │                │  search_hotels │
 │                   │                   │                 │                │                │
 │                   │ 14. LLM 响应(第2次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_hotels",   │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "city": "上海",                │                │                │
 │                   │        "check_in": "明天",            │                │                │
 │                   │        "check_out": "后天"            │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 15. 再次路由到子Agent│                │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 16. 执行        │                │                │
 │                   │                   │     search_hotels│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 查询酒店       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回酒店列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 17. 返回酒店结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 18. 再次调用 LLM   │                 │                │                │
 │                   │     (决定下一步)   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析酒店      │
 │                   │                   │                 │                │  推荐最佳套餐  │
 │                   │                   │                 │                │  生成工具调用: │
 │                   │                   │                 │                │  book_trip     │
 │                   │                   │                 │                │                │
 │                   │ 19. LLM 响应(第3次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "book_trip",       │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "flight_id": "CA123",         │                │                │
 │                   │        "hotel_id": "SH001"           │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 20. 路由到子Agent  │                 │                │                │
 │                   │ (预订差旅)         │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 21. 执行book_trip│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 创建订单       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回订单号     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 22. 返回预订结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    {                                 │                │                │
 │                   │      "trip_id": "TRIP_001",          │                │                │
 │                   │      "status": "confirmed",          │                │                │
 │                   │      "total_cost": 3000              │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 23. 调用Calendar MCP│                │                │                │
 │                   │     添加日程        │                │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 添加日历事件   │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回事件ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 24. 调用Payment MCP│                 │                │                │
 │                   │     处理支付        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 创建支付单     │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回交易ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 25. 调用Alert MCP  │                 │                │                │
 │                   │     发送通知        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 记录通知        │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │ 26. 最后调用 LLM   │                 │                │                │
 │                   │     生成友好回复   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │ 总结整个过程    │
 │                   │                   │                 │                │ 生成用户友好    │
 │                   │                   │                 │                │ 的文字回复      │
 │                   │                   │                 │                │                │
 │                   │ 27. LLM 最终响应   │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    "好的,已为您预订了从北京       │                │                │
 │                   │     到上海的差旅。您的订单号是    │                │                │
 │                   │     TRIP_001,总费用3000元。     │                │                │
 │                   │     已添加到您的日程,并发送

本质上一句话总结:对话发起后,主Agent构建基础提示词进行首轮行为分析后,然后按需注入子Agent来递归/循环完成一轮对话。

结尾

如上就非常简单直观的结合代码,讲解了现在LLM大模型应用的核心架构和角色拆解。

希望对大家有所帮助。

使用uniapp vue2开发微信小程序时,图片处理插件

2026年1月21日 17:47

vue3处理插件

参考juejin.cn/post/738574…

因为上面的文章中提出的例子在vue2中并不生效, 因此单独写了一个针对vue2使用的loader.

实现1: 通过字符串替换方式处理

这个方式的缺点是较为死板, 无法处理模板字符串和表达式相关, 但是对于src=xxx的类型有较好的匹配

 module.exports = function (source) {
  console.log("----customLoader original content----", source);
  function replaceImageSrcInVue(content) {
    
    content = content.replace(
      /(<template[\s\S]*?>)([\s\S]*?)(<\/template>)/,
      (match, start, middle, end) => {
        // 替换 <image ... src="..." ...>
        const replaced = middle.replace(
          /(<image\b[^>]*?\bsrc=)(['"])([^'"]+)\2/gi,
          (imgMatch, prefix, quote, src) => {
            // 只替换非 http/https 开头的 src
            if (/^https?:\/\//.test(src)) return imgMatch;
            console.log(
              "----customLoader src----",
              imgMatch,
              "  prefix:",
              prefix,
              "   src:",
              src,
            );
            return `${prefix}${quote}${"https://www.xxx.com/"}${src}${quote}`;
          },
        );
        return start + replaced + end;
      },
    );
    return content;
  }
  return replaceImageSrcInVue(source);
};

实现2: 基于ast

这个模式的优点是可以精确匹配到image对应的src属性, 还可以对于绑定src的属性中的模板字符串和字符串类型进行处理, 比如说以下代码, 同时也可以很方便扩展到其他类型的元素中, 比如video等.

:src="isActive ? `${activeHost}/logo.png` : '/staticHost/logo.png'"

依赖编译器版本为yarn add -D @@vue/compiler-sfc@3.5.26

详细实现方式如下:

const compiler = require("@vue/compiler-sfc");

module.exports = function (source) {
  const options = this.getOptions();
  let { publicPath: staticHost, sourceDir } = options || {};
  if (staticHost.endsWith("/")) {
    staticHost = staticHost.slice(0, -1);
  }
  try {
    const sfc = compiler.parse(source, {
      templateParseOptions: { parseMode: "sfc" },
    });
    if (!sfc.descriptor.template) {
      return source;
    }
    let content = sfc.descriptor.template.content;
    const ast = sfc.descriptor.template.ast;
    const tempLen = "<template>".length; // 10, loc是基于整个文件的偏移量,需要减去前面的长度
    const traverseAst = (node) => {
      if (!node) return;
      if (node.children && node.children.length) {
        for (let i = node.children.length - 1; i >= 0; i--) {
          traverseAst(node.children[i]);
        }
      }
      const doReplace = (loc, oldValue) => {
        if (oldValue.startsWith(sourceDir)) {
          const newValue =
            '"' + oldValue.replace(sourceDir, `${staticHost}/`) + '"';
          content =
            content.slice(0, loc.start.offset - tempLen) +
            newValue +
            content.slice(loc.end.offset - tempLen);
        }
      };
      if (node.type === 1 && node.tag === "image") {
        // console.log("Found <image> node:", node);
        const srcAttr = node.props.find(
          (prop) => prop.name === "src" && prop.type === 6,
        );
        if (srcAttr) {
          console.log("Original src value:", srcAttr);
          const srcValue = srcAttr.value.content;
          const loc = srcAttr.value.loc;
          doReplace(loc, srcValue);
        } else {
          const bindSrcAttr = node.props.find(
            (prop) =>
              prop.name === "bind" &&
              prop.type === 7 &&
              prop.rawName === ":src",
          );
          // console.log("Bind src attribute:", bindSrcAttr);
          if (!bindSrcAttr) return;

          const ast = bindSrcAttr.exp.ast;
          const loc = bindSrcAttr.exp.loc;
          // 处理简单的模板字符串情况, 只需要遍历处理template和字符串类型就可以
          // 这里可能包含的类型为三目预算符和逻辑运算符
          const traverseBindAst = (bindNode, loc) => {
            if (!bindNode) return;
            // 逻辑运算符|| 或者 &&
            if (bindNode.type === "LogicalExpression") {
              traverseBindAst(bindNode.right, loc);
              traverseBindAst(bindNode.left, loc);
            } else if (bindNode.type === "ConditionalExpression") {
              // 三目运算符
              traverseBindAst(bindNode.alternate, loc);
              traverseBindAst(bindNode.consequent, loc);
              traverseBindAst(bindNode.test, loc);
            } else if (bindNode.type === "TemplateLiteral") {
              // 模板字符串类型
              if (bindNode.quasis && bindNode.quasis.length > 0) {
                const indexLoc = bindNode.quasis[0].loc;
                const value = bindNode.quasis[0].value.cooked;
                if (value.startsWith(sourceDir)) {
                  const newValue = value.replace(sourceDir, `${staticHost}/`);
                  content =
                    content.slice(
                      0,
                      loc.start.offset - tempLen + indexLoc.start.index - 1,
                    ) + // -1 是因为模板字符串的 ` 符号占位
                    newValue +
                    content.slice(
                      loc.start.offset - tempLen + indexLoc.end.index - 1,
                    );
                }
              }
            } else if (bindNode.type === "StringLiteral") {
              // 字符串类型
              const indexLoc = bindNode.loc;
              const value = bindNode.value;
              if (value.startsWith(sourceDir)) {
                const newValue = value.replace(sourceDir, `${staticHost}/`);
                content =
                  content.slice(
                    0,
                    loc.start.offset - tempLen + indexLoc.start.index, // 这里不减是需要保留 "" 符号
                  ) +
                  newValue +
                  content.slice(
                    loc.start.offset - tempLen + indexLoc.end.index - 2,
                  ); // -2 是因为字符串的 "" 符号占位
              }
            }
          };
          traverseBindAst(ast, loc);
        }
      }
    };
    traverseAst(ast);
    // 替换 template 内容
    const loc = sfc.descriptor.template.loc;
    const newSource = source.slice(0, loc.start.offset) + content + source.slice(loc.end.offset);
    return newSource;
  } catch (err) {
    console.error("Error parsing SFC:", err);
    return source;
  }
}

在vue.config.js中的用法

chainWebpack: (config) => {
      config.module
        .rule("vue")
        .use("vue-loader")
        .end()
        .use("customLoader")
        .loader(path.resolve(__dirname, "./customLoader.js"))
        .options({
          publicPath: "https://xxx.com",
          sourceDir: '/staticHost/',
        })
        .end();
  }

ps

如果遇到报错this.getConfig不存在, 则可以把config配置项写到load.js里面中...

CSS 盒子又“炸”了?一文看懂标准盒模型 vs 怪异盒模型

作者 Flinton
2026年1月21日 17:40

大家好,我叫【小奇腾】,你们有没有遇到过这种情况?明明设置了两个 width: 50% 的盒子想让它们并排,结果右边那个死活都要掉到下一行去?

难道是 50% + 50% > 100%?数学老师骗了我们? 不,是 CSS 盒模型 在“欺骗”你的眼睛。

今天这节课,我们不背枯燥的概念

本期详细的视频教程bilibili:CSS 盒子又“炸”了?一文看懂标准盒模型 vs 怪异盒模型

一、盒子的“解剖学”:洋葱是怎么剥开的?

在开始区分”标准盒模型 vs 怪异盒模型“之前,我们先了解什么是盒子模型的基本组成,想象你现在手里拿着一个橘子🍊,准备送给朋友。CSS 的盒子模型(Box Model)和这个橘子🍊一模一样,由从内到外的四层组成:

  • Content(果肉) :最核心好吃的那个部分

  • Padding(果皮) :保护果肉的缓冲层。注意:果皮和果肉是一体的,果肉烂了(背景色),果皮通常也是那个颜色。

  • Border(包装盒) :最外层的硬壳。它是橘子和外界的分界线

  • Margin(社交距离) :这一箱橘子和那一箱苹果之间,必须留出的空气缝隙

划重点:Margin 是用来推开别人的,不属于盒子本身的大小;而 Content + Padding + Border 才是盒子自己的“肉身”。

盒子模型的示意图

在浏览器,自己写一个盒子,然后通过检查工具,就可以看到盒子模型的样子。

.box {
    width: 200px;
    height: 200px;
    border: 10px solid #ccc;
    padding: 30px;
    margin: 20px;
}

<div class="box"></div>
  • 盒子模型图

image.png

  • 盒子模型的每个部分(当我的鼠标放在盒子模型上)
    • content(内容区) 宽度200 x 高度200
    • padding(内边距) 4个内边距都是 30
    • border(边框) 4条边框都是 10
    • margin(外边距) 4个外边距都是 20

image.png

二、 直觉的陷阱:你要买多大的橘子?

在我们的直觉里,如果我们买一个宽 100px 的盒子,那这个盒子占的地方应该就是 100px,对吧?

但在 CSS 的标准盒模型(Standard Box Model) 里,逻辑是反直觉的。

🍊 橘子比喻

想象你去买橘子。

  • Content(内容区) :是橘子果肉。
  • Padding(内边距) :是橘子皮。
  • Border(边框) :是包装盒。

当你写下 width: 100px 时,你以为你控制了整个橘子的大小。 实际上,你只控制了“橘子果肉”的大小。

如果你给这个橘子穿上 20px 厚的皮(padding),再套上 5px 厚的壳(border)。 浏览器是这样算账的(做加法):

实际占地宽度 = 果肉(100) + 左皮(20) + 右皮(20) + 左壳(5) + 右壳(5) 结果 = 150px!

💥 案发现场

你有一个盒子里面装了两个子盒子,里面两个子盒子你设置了 width: 50%,但只要你加了一丁点 paddingborder,这个盒子的实际宽度就变成了 50% + 皮。 两个胖子挤在一起,总宽度超过了 100%,父容器装不下,右边的胖子就被挤下去了。这就是标准盒模型给新手挖的最大的坑。

代码示例

从代码中,可以看到给两个子元素都给的50%的宽度,按道理是应该平并排在.box这个父盒子里的,但是却掉下来了一个.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .box {
            width: 1000px;
            display: flex;
            flex-wrap: wrap;
            border: 4px solid purple;
        }

        .left {
            width: 50%;
            padding: 20px;
            border: 5px solid #ccc;
            background-color: red;
        }

        .right {
            width: 50%;
            padding: 20px;
            border: 5px solid blue;
            background-color: green;
        }
    </style>
</head>

<body>
    <div class="box">
        <div class="left"></div>
        <div class="right"></div>
    </div>
</body>

</html>

效果图:

二、 救星登场:怪异盒模型(Border-box)

为了解决这个问题,CSS 提供了一个属性,虽然它以前被称为“怪异盒模型”(Quirks Mode),但我觉得它应该叫**“省心盒模型”**。

即:box-sizing: border-box;

📦 快递箱比喻

在这个模式下,盒子就像一个快递箱。 当你写下 width: 100px 时,这个箱子死锁就是 100px 宽,雷打不动。

如果你非要往里面塞 20px 的泡沫(padding):

  • 泡沫可以被压缩,箱子外壳不会变大(不会撑破布局)。
  • 只能委屈里面的空间(Content)变小

计算这里发生了什么

还是刚才的数据,但这次我们加上了 box-sizing: border-box 给到两个子盒子;

  • CSS 设置width: 100px, padding: 20px, border: 5px

  • 浏览器实际渲染宽度100px(不用算了,就是它!)

  • 里面的内容还能剩多少空间?

    100px (总宽) - 40px (左右皮) - 10px (左右壳) = 50px

虽然内容区被挤小了,但你的页面布局稳如泰山,绝对不会乱跑!

三、 终极一招:一行代码走天下

在实际开发中,我们不想每次写个 div 都要掏出计算器算宽度。怪异盒模型”好用也更符合直觉, 比如淘宝、京东页面,前端工程师们都会在CSS的第一行加上box-sizing: border-box

这句话翻译过来就是:

“浏览器你给我听好了!从现在开始,我说这个盒子宽 100px,它就是 100px。不管我加多少内边距和边框,你都得自己在内部消化,绝对不准把盒子撑大!”

四、总结一下

  1. 盒子四要素:Content(橘子果肉)、Padding(橘子果皮)、Border(包装壳)、Margin(橘子和其他物品距离)。
  2. 标准盒模型width 只管肉,加皮会变胖(容易炸布局)。
  3. 怪异盒模型width 管整体,加皮肉变少(布局超稳定)。
  4. 建议:开局一条 box-sizing: border-box,写代码少掉很多头发。

前后端分离开发实战:从等后端到自己造数据

2026年1月21日 17:03

遇到的真实问题

刚入行那会儿,我经常遇到这种尴尬情况:写好了页面布局,准备连接后端接口,结果后端同事说:"接口还没写完,你先等等。"

等啊等,一等就是一周,有时候甚至两周。我只能在那干坐着,或者写一些无关紧要的代码,感觉特别被动。

后来老鸟告诉我:"兄弟,你不用等后端的,自己先造点假数据跑起来,等后端接口出来后再换掉就行了。"

我当时还不信,直到看到Mock.js这个工具,才发现原来前端开发可以这么爽!

什么是前后端分离?

简单说,就是前端只管页面和交互,后端只管数据和业务逻辑。就像两个人合作做菜,一个人负责摆盘(前端),一个人负责炒菜(后端),最后合成一道完整的菜。

但是,如果摆盘的师傅等炒菜的师傅先做好菜,那整个流程就很慢。所以聪明的做法是,摆盘师傅先拿一些假菜练习摆盘,等真菜来了再换上去。

Mock.js:前端的"造物主"

Mock.js就像是前端开发者的"造物主",可以凭空变出各种数据来。比如我要100篇文章,它就能瞬间给我100篇;我要用户信息,它也能马上生成。

安装和使用

bash

npm install mockjs

然后就可以开始"造数据"了:

javascript

import Mock from 'mockjs'

// 比如我要造10篇文章的数据
const articles = Mock.mock({
    'list|10': [{  // 生成10条数据
        'id|+1': 1,  // ID从1开始递增
        'title': '@ctitle(10, 30)',  // 随机中文标题,10-30个字符
        'content': '@cparagraph(3, 10)',  // 随机中文段落,3-10句话
        'author': '@cname',  // 随机中文姓名
        'date': '@date("yyyy-MM-dd")'  // 随机日期
    }]
})

console.log(articles.list)  // 就能看到10条随机文章数据

是不是很神奇?几行代码就能生成看起来很真实的测试数据。

实战:博客文章列表功能

我们来做一个具体的例子:博客文章列表页面。这个页面需要显示文章列表,还要有分页功能。

先看接口长什么样

一般后端会给我们这样的接口文档:

text

GET /api/posts?page=1&limit=10

返回数据格式:
{
  "code": 200,
  "msg": "success",
  "data": {
    "items": [...],  // 文章列表
    "pagination": {
      "current": 1,  // 当前页
      "limit": 10,   // 每页数量
      "total": 100,  // 总数
      "totalPage": 10  // 总页数
    }
  }
}

用Mock.js造数据

javascript

import Mock from 'mockjs'

// 定义文章标签
const tags = ["前端", "后端", "AI", "职场", "面试", "算法"]

// 造45篇文章数据
const posts = Mock.mock({
    'list|45': [{
        'id|+1': 1,  // ID自增
        'title': '@ctitle(8, 20)',  // 中文标题
        'brief': '@cparagraph(1, 3)',  // 文章简介
        'totalComments|0-50': 1,  // 评论数0-50
        'totalLikes|0-1000': 1,  // 点赞数0-1000
        'publishedAt': '@datetime("yyyy-MM-dd HH:mm")',  // 发布时间
        'user': {  // 用户信息
            'id|1-10': 1,  // 用户ID 1-10
            'name': '@cname',  // 用户姓名
            'avatar': '@image("100x100", "#ccc", "#fff", "avatar")'  // 头像
        },
        'tags': function() {  // 标签,随机选2个
            return Mock.Random.pick(tags, 2)
        },
        'thumbnail': '@image("300x200", "#eee", "#999", "thumb")'  // 缩略图
    }]
}).list

// 定义Mock接口
export default [
    {
        url: '/api/posts',
        method: 'get',
        response: ({ query }) => {
            // 获取分页参数
            const { page = '1', limit = '10' } = query
            const currentPage = parseInt(page)
            const size = parseInt(limit)

            // 参数校验
            if (isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1) {
                return {
                    code: 400,
                    msg: '页码或每页数量参数错误',
                    data: null
                }
            }

            // 计算分页数据
            const total = posts.length
            const start = (currentPage - 1) * size
            const end = start + size
            const paginatedData = posts.slice(start, end)

            return {
                code: 200,
                msg: 'success',
                data: {
                    items: paginatedData,
                    pagination: {
                        current: currentPage,
                        limit: size,
                        total: total,
                        totalPage: Math.ceil(total / size)
                    }
                }
            }
        }
    }
]

代码解释

让我解释一下这段代码的关键部分:

  1. @ctitle(8, 20) :生成8-20个字符的中文标题
  2. @datetime("yyyy-MM-dd HH:mm") :生成格式化的日期时间
  3. Mock.Random.pick(tags, 2) :从tags数组中随机选择2个标签
  4. 'id|+1': 1:ID从1开始递增
  5. 分页逻辑(currentPage - 1) * size计算起始位置

如何在Vite项目中使用

在你的vite.config.js中加入:

javascript

import { defineConfig } from 'vite'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    viteMockServe({
      mockPath: 'mock',  // mock文件夹位置
      enable: true,      // 开启mock
    })
  ]
})

这样启动项目后,访问/api/posts?page=1&limit=10就能得到Mock数据了。

为什么这样做很好?

1. 不用等后端了

以前:前端 → 等后端 → 开发
现在:前端 → Mock数据 → 开发 → 换真实接口

2. 可以测试边界情况

用Mock数据,我们可以轻松测试各种边界情况:

  • 空数据列表
  • 错误参数
  • 大量数据
  • 网络超时

3. 数据格式可控

Mock数据完全由前端控制,可以确保数据格式符合前端需求。

4. 提高开发效率

前端可以专注于页面交互和用户体验,不用被后端进度拖累。

实际开发中的注意事项

1. Mock数据要接近真实

Mock的数据格式要尽量和真实接口保持一致,否则后面对接口时会有麻烦。

2. 接口文档要明确

前后端最好先确定好接口文档,包括:

  • 请求路径
  • 请求方法
  • 参数格式
  • 返回数据结构

3. 错误处理也要Mock

不仅要Mock正常情况,还要Mock错误情况,比如网络错误、参数错误等。

真实接口来了怎么办?

当后端接口开发完成后,只需要修改请求的基础URL:

javascript

// 开发环境用Mock
const BASE_URL = import.meta.env.DEV ? '' : 'https://api.real.com'

// 发请求
fetch(`${BASE_URL}/api/posts?page=1&limit=10`)

或者在axios中配置:

javascript

// 开发环境
if (process.env.NODE_ENV === 'development') {
  axios.defaults.baseURL = ''  // Mock接口
} else {
  axios.defaults.baseURL = 'https://api.real.com'  // 真实接口
}

小结

通过Mock.js,前端开发者可以:

  • 摆脱对后端的依赖
  • 快速验证UI和交互
  • 提高开发效率
  • 更好地测试各种场景

这种开发模式已经成为现代前端开发的标准做法。下次再遇到后端没写完接口的情况,你就可以自信地说:"没关系,我自己造数据!"

❌
❌