普通视图
GDAL 实现影像裁剪
vite.config.js 8 大核心模块,一文吃透
《实时渲染》第2章-图形渲染管线-2.3几何处理
都2026年,React源码还值不值得读 ❓❓❓
前端ESLint 和 Babel对比
前端向架构突围系列模块化 [4 - 1]:思想-超越文件拆分的边界思维
数据语义层 vs 宽表模式:哪种架构更适合 AI 时代的数据分析?
vue2+vue3 Table表格合并
手把手实现链表:单链表与双链表的完整实现
写给前端同学的 21 条职场教训
大文档也能丝滑流式渲染?试试Markstream-Vue,体验前所未有的Markdown流畅感!
为什么选择Markstream-Vue?只因它“流”得够快!
- 🚀 真·流式渲染,支持超大文档、实时预览,边写边看不卡顿
- 🏆 彻底解决传统Markdown组件渲染慢、卡顿、内存暴涨等痛点
- 🧩 组件化设计,Vue 3项目即插即用,API极简
- 🎨 支持代码高亮、公式、流程图等丰富内容,体验无缝流畅
- 🔥 SSR/静态站点/移动端全兼容,性能拉满
真实场景,极致体验
- 技术文档、知识库、长篇小说、实时协作编辑器……再大的内容都能流畅渲染
- 支持内容“边加载边渲染”,让用户体验“所见即所得”的极致流畅
3步上手,流式体验立享
- 安装依赖
pnpm add markstream-vue
- 引入组件
<Markstream :source="longMarkdown" stream />
- 享受流式渲染的丝滑体验!
你的Star,是我持续优化的最大动力!
👉 GitHub地址
一杯茶时间,带你用 RWKV 并发模型做 VS Code 多候选代码补全插件 🤔🤔🤔
在写这份实现教程之前,我已经把该插件的一个版本发布到了 VS Code 扩展市场,在市场中搜索 rwkv 即可找到,你可以先安装试用,再决定是否跟着下文从零实现一版。
本文以这款基于 RWKV 模型的智能代码补全插件为例,讲解从零实现 VS Code 扩展的思路与步骤,并说明如何接入 rwkv_lightning 后端。
该插件通过并发推理一次返回多个不同的补全答案供选择,在侧边栏展示,方便在多种写法之间对比、挑选后再插入,适合写一半、让模型多想几种实现的编码方式;光标后有代码时自动走 FIM(Fill-in-the-Middle)接口做中间填充,否则走普通续写。全文按功能目标、代码实现(项目结构、补全触发、API 调用、Webview 展示与插入)、后端接入组织,后端部分包含硬件要求、模型准备、与 Albatross 的关系、启动服务、模型加载机制、HTTP API、快速测试以及插件配置与验证,文末附常见问题。
下图为在编辑器中触发补全后,并发推理得到的多个不同答案在侧边栏展示、点击即可插入到光标位置的情形。
![]()
前端项目地址:rwkv-code-completion
后端项目地址:rwkv_lightning
一、我们要做怎样的功能
动手写代码之前,首先要考虑我们要实现一个什么样的 VS Code 插件,这决定了后续的架构与实现方式。
在本例中,我们想做一款智能代码补全插件,并事先想清楚四件事。补全结果通过并发推理一次返回多个不同的答案,在侧边栏展示供用户选择,点选后插入。根据光标后是否已有代码,在 FIM(Fill-in-the-Middle)与普通续写之间自动切换接口。在空格、换行、删除等操作时自动触发,并做好防抖与取消,避免频繁请求。服务地址、密码、生成长度、采样参数(temperature、top_p)、候选数量、防抖延迟等通过 VS Code 设置暴露。把这四件事的对应关系梳理出来,大致如下:
![]()
把这些想清楚之后,再按代码实现过程和如何接入后端两部分往下做。
二、代码实现过程
2.1 项目结构
用 yo code 或手工 scaffold 一个扩展后,核心只需两个源码文件,职责分开,与 VS Code 打交道的放一边,与后端 HTTP 打交道的放另一边,方便维护和单测。
-
src/extension.ts作为插件入口,在activate里实现CompletionItemProvider、注册补全、用onDidChangeTextDocument监听编辑并按条件触发补全;拿到候选列表后,不再往原生 suggest 里塞,而是创建 Webview、渲染多条结果,并处理用户点击插入与插完再补全。 -
src/completionService.ts负责补全服务,根据有无 suffix 选择调用普通续写接口或 FIM 接口,组装请求体、发fetch、解析data.choices为string[],并透传AbortSignal以支持取消。
两者与后端的关系可以概括为:
![]()
在 package.json 里,main 指向打包后的入口(如 ./dist/extension.js),VS Code 按它加载扩展;activationEvents 可设为 onStartupFinished,这样只在 IDE 就绪后才激活,避免启动时卡顿;contributes.configuration 声明 enabled、baseUrl、password、maxTokens、temperature、topP、numChoices、debounceDelay 等,用户改设置后可通过 vscode.workspace.getConfiguration("rwkv-code-completion") 读到。
构建可用 esbuild 或 tsc,把 extension.ts 等打出到 dist,调试和发布都从 dist 走。
2.2 激活与补全触发
激活时在 activate(context) 里完成两件事,一是向 VS Code 注册谁在什么情况下提供补全,二是监听文档变更,在特定编辑动作后自动调出补全,用户不必每次手动按 Ctrl+Space。
实现 vscode.CompletionItemProvider 的 provideCompletionItems(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 还不够,例如用户输入 a、b、c 时也会触发,容易导致敲一个字母就发一次请求。因此再加一层文档变更的过滤,用 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),用户停一会儿才发请求,减少连打时的无效调用。还可以加两条限制,一是只处理当前活动编辑器的文档,避免在切文件、分屏时误触发,二是与上一次触发至少间隔几百毫秒,进一步避免短时间内重复弹补全。整体触发链路如下:
![]()
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 个补全并可取消。五步关系如下:
![]()
CompletionService.getCompletion 内部按 suffix 是否非空分支,有后缀则认为用户在中间写代码,走 FIM,否则走普通续写。接口选择如下:
![]()
例如下面这样。
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 里 contents 填 Array(numChoices).fill(prefix),即同一段 prefix 复制多份,利用后端批量接口一次推理出多条不同采样结果;再配上 stream: false、password、max_tokens、temperature、top_p、stop_tokens 等。返回的 data.choices 里,每条取 choice.message?.content || choice.text,trim 掉首尾空白并滤掉空串,得到 string[]。
FIM 补全走 callFIMAPI,请求 POST {baseUrl}/FIM/v1/batch-FIM。prefix、suffix 各为长度为 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: true、retainContextWhenHidden: true 以便跑脚本和在切走时保留状态。panel.webview.html 由 getWebviewContent(completions, languageId) 生成。在打开面板之前,必须把当时的 document 和 position 存到闭包或变量里,因为 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,completions 在 getWebviewContent 里用 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,让光标后的新内容再触发一轮补全,形成补全、选一、再补全的连贯体验。从拿到结果到插入再触发的流程如下:
![]()
原生 suggest 只能一条条、样式固定,没法同时展示多条并发结果和自定义交互;用 Webview 可以自己布局、自己处理点击和插入,更适合并发推理、多答案选一的用法。
三、如何接入后端
插件通过 HTTP 调用 rwkv_lightning,需要先部署后端,再在 VS Code 里填好配置。扩展详情页会标注后端部署与配置说明,便于快速上手,下图为扩展市场中的页面示意。
![]()
接入后端的整体步骤如下。
![]()
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 在初始化时会执行以下步骤:
- 读取权重文件:使用
torch.load(args.MODEL_NAME + '.pth', map_location='cpu')将权重加载到 CPU 内存 - 数据类型转换:将权重转换为半精度(
dtype=torch.half)以节省显存 - 设备迁移:根据硬件平台将权重移动到 GPU
- NVIDIA GPU:使用
device="cuda" - AMD GPU:使用 ROCm 的 HIP 运行时
- NVIDIA GPU:使用
词表加载
词表文件 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:8000 或 http://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 |
防抖延迟(毫秒) |
150–300
|
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 或弹窗报错,检查 baseUrl、password、端口与防火墙。
四、常见问题
为何不能在 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的方案设计
前言
如今的大模型应用架构设计基本都是一个主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都集成于该类,核心触发如下能力。
- 上下文管理;
- 大模型调用;
- 提示词注入;
// 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>
}
有了Agent、Mcp,本质上完整的一次自然语言对话 -> 反馈的系统流转图就很清晰了。
基于这套框架来扩展即可。
一次完整对话到反馈的时序图大概是这样:
用户 主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开发微信小程序时,图片处理插件
vue3处理插件
因为上面的文章中提出的例子在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 怪异盒模型
大家好,我叫【小奇腾】,你们有没有遇到过这种情况?明明设置了两个
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>
- 盒子模型图
![]()
- 盒子模型的每个部分(当我的鼠标放在盒子模型上)
- content(内容区)
宽度200 x 高度200 - padding(内边距)
4个内边距都是 30 - border(边框)
4条边框都是 10 - margin(外边距)
4个外边距都是 20
- content(内容区)
![]()
二、 直觉的陷阱:你要买多大的橘子?
在我们的直觉里,如果我们买一个宽 100px 的盒子,那这个盒子占的地方应该就是 100px,对吧?
但在 CSS 的标准盒模型(Standard Box Model) 里,逻辑是反直觉的。
🍊 橘子比喻
想象你去买橘子。
- Content(内容区) :是橘子果肉。
- Padding(内边距) :是橘子皮。
- Border(边框) :是包装盒。
当你写下 width: 100px 时,你以为你控制了整个橘子的大小。 实际上,你只控制了“橘子果肉”的大小。
如果你给这个橘子穿上 20px 厚的皮(padding),再套上 5px 厚的壳(border)。 浏览器是这样算账的(做加法):
实际占地宽度 = 果肉(100) + 左皮(20) + 右皮(20) + 左壳(5) + 右壳(5) 结果 = 150px!
💥 案发现场
你有一个盒子里面装了两个子盒子,里面两个子盒子你设置了 width: 50%,但只要你加了一丁点 padding 或 border,这个盒子的实际宽度就变成了 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。不管我加多少内边距和边框,你都得自己在内部消化,绝对不准把盒子撑大!”
四、总结一下
- 盒子四要素:Content(橘子果肉)、Padding(橘子果皮)、Border(包装壳)、Margin(橘子和其他物品距离)。
-
标准盒模型:
width只管肉,加皮会变胖(容易炸布局)。 -
怪异盒模型:
width管整体,加皮肉变少(布局超稳定)。 -
建议:开局一条
box-sizing: border-box,写代码少掉很多头发。
前后端分离开发实战:从等后端到自己造数据
遇到的真实问题
刚入行那会儿,我经常遇到这种尴尬情况:写好了页面布局,准备连接后端接口,结果后端同事说:"接口还没写完,你先等等。"
等啊等,一等就是一周,有时候甚至两周。我只能在那干坐着,或者写一些无关紧要的代码,感觉特别被动。
后来老鸟告诉我:"兄弟,你不用等后端的,自己先造点假数据跑起来,等后端接口出来后再换掉就行了。"
我当时还不信,直到看到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)
}
}
}
}
}
]
代码解释
让我解释一下这段代码的关键部分:
-
@ctitle(8, 20):生成8-20个字符的中文标题 -
@datetime("yyyy-MM-dd HH:mm"):生成格式化的日期时间 -
Mock.Random.pick(tags, 2):从tags数组中随机选择2个标签 -
'id|+1': 1:ID从1开始递增 -
分页逻辑:
(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和交互
- 提高开发效率
- 更好地测试各种场景
这种开发模式已经成为现代前端开发的标准做法。下次再遇到后端没写完接口的情况,你就可以自信地说:"没关系,我自己造数据!"