MoreRSS

site iconGamea | 飘逝的风修改

游戏后台开发十多年, 目前就职于Tencent。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Gamea | 飘逝的风的 RSS 预览

解放双手的AI绘图:三种方式让AI帮你绘制Excalidraw图表

2025-08-31 08:00:00

Featured image of post 解放双手的AI绘图:三种方式让AI帮你绘制Excalidraw图表

难得出游,一边吹着海风,看着AI帮我在一片空白的白板上慢慢添加各种元素,也是另一种触感。牛马在驱动牛马 :)

前言

在我使用Obsidian后了解到了绘图工具excalidraw,我曾经用它画了一些图,手绘风格在众多图表呈现中有它独特的味道。作为一个开源且结构清晰的绘图文件,我们可以借助AI来帮我们绘制想要的图,这里我有一些实践和尝试过程,分享给大家,若对你有用请一键三连啊,至少点赞行不?

本文分享的自动绘图不止适用于在Obsidian的Excalidraw插件中,同时也支持网页端(如官方)自动绘图。

原理解释

我在Obsidian中随便画了一个图,这个文件从文本视角内容大概是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---

excalidraw-plugin: parsed
tags: [excalidraw]

---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'


# Excalidraw Data

## Text Elements
Hello ^vcBSVLYg

world ^SroqyGoR

kevin ^OpoVzdNT

%%
## Drawing
```compressed-json
N4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebQBGAFZtAAYaOiCEfQQOKGZuAG1wMFAwMogSbghsQgoAaQB9ADkmlIpiACUAdgAZADEAKQBOZQAtZwaedLLIWEQqwOwojmVg
省略后续...

将这个文件丢给AI分析了一下,给了如下的描述:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Excalidraw .excalidraw.md 格式说明

- 用途:由 Obsidian Excalidraw 插件生成/维护的绘图文件(Markdown 包装 + 压缩场景数据)。
- 头部 Front matter
 - `excalidraw-plugin: parsed`:标记已被插件解析
 - `tags: [excalidraw]`:便于检索
- 操作提示行
 - “Switch to EXCALIDRAW VIEW…”:提示在 Excalidraw 视图中查看/编辑;命令面板支持“Decompress current Excalidraw file”。
- 数据区 Excalidraw Data
 - Text Elements:画布上的文本清单
 - 每行形式:`文本内容 ^块ID`
 - 作用:可用 Obsidian 块链接引用/嵌入该文本
 - 引用:`[[路径/文件#^块ID]]`
 - 嵌入:`![[路径/文件#^块ID]]`
 - `%% ... %%` 注释包裹
 - Obsidian 注释语法;用于隐藏机器数据,避免在普通 Markdown 视图中干扰阅读。
 - Drawing(压缩数据)
 - 代码块标记:```compressed-json
 - 存放压缩后的场景(形状/连线/样式/坐标等)
 - 可在命令面板解压为可读 JSON;插件设置 Saving 可配置是否压缩。
- 编辑建议
 - 不要手工改动 `compressed-json` 内容;请在 Excalidraw 视图中编辑,以免损坏绘图。
- 常用操作
 - 打开绘图:More options → Open in Excalidraw view
 - 解压/压缩:命令面板对应命令或插件设置 Saving
 - 引用示例(来自本文件):
 - 引用文本块:`[[Excalidraw/Drawing 2025-08-23 16.22.25.excalidraw.md#^vcBSVLYg]]`
 - 嵌入文本块:`![[Excalidraw/Drawing 2025-08-23 16.22.25.excalidraw.md#^SroqyGoR]]`
- 本文件中的文本元素
 - "Hello" `^vcBSVLYg`
 - "world" `^SroqyGoR`
 - "kevin" `^OpoVzdNT`

既然内容只是一个JSON,有压缩标识也可以不压缩,那么让AI帮我们生成内容难度就不大了。 我们先看看市面上当前有哪些方案以及它们的特点。

初阶尝试:借助于mcp_excalidraw绘图

我搜索到有个MCP server (mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw),可以简单的启用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 克隆代码
git clone https://github.com/yctimlin/mcp_excalidraw.git
cd mcp_excalidraw
npm install

# 构建前端
npm run build

# 启动canvas server
npm run canvas

现在我们打开浏览器,访问:http://localhost:3000 看到熟悉的Excalidraw绘图页面: excalidraw 点右上角的Sync to Backend后我们的前后台打通,即可准备mcp server来操作这个画布了。以下是mcp-server的配置(替换成你的本地路径):

1
2
3
4
5
6
{
 "excalidraw": {
 "command": "node",
 "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
 }
}

使用如下prompt:

请使用excalidraw mcp插件绘制一个太阳系及行星的运行图

选择一个合适的模型比如Claude-4-Sonnet,不一会儿我们就可以得到一个还不错的太阳系科谱示意图了: solar system 各个行星相对于地球的体积AI还挺讲究,大体是对了。

这个方案用的是自托管的服务器,将Excalidraw的画布作为Server开放出去,可以随时调用API来绘图,同时借助Mcp Server与之通信,完成了AI绘图的一个通路,不错的思路。 那么,既然在网页上绘图,咱能不能直接在 Excalidraw 官方绘图网站上直接使用AI绘图呢?

进阶实践:在 Excalidraw 官方网站AI绘图

我这里说的不是官方网站的AI绘图功能,它需要开通会员,而且我在免费试用期间就感觉到它还比较简单,AI画不了啥有用的图,但别慌,我们有黑科技! 我们先介绍一个强大的Chrome浏览器插件mcp-chrome插件:

🌟 让chrome浏览器变成你的智能助手 - 让AI接管你的浏览器,将您的浏览器转变为强大的 AI 控制自动化工具。

可以在GitHub根据文档下载安装,之后打开它:

mcp-chrome-install

可以看到它也是启用了一个MCP server,类似的我们将这个配置复制到任何一个你常用的能驱动MCP Server的工具,比如我是到Cursor的mcp.json中,顺手它改个名字叫mcp-chrome

1
2
3
4
5
6
7
8
{
 "mcpServers": {
 "mcp-chrome": {
 "type": "streamable-http",
 "url": "http://127.0.0.1:12306/mcp"
 }
 }
}

我们可以刷新一下工具,可以看到它有这些能力: mcp-chrome-tools

原来它的实现原理是向页面中注入一些脚本,之后通过HTTP和页面通信,并基于我们发给它的消息内容调用相应的页面工具。可是咱们是要在这个页面绘图呢,这就需要另一个东西 excalidraw-prompt了,你同样可以在上面的repo的prompt中获取到。我们可以简单看下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
角色
你是一位顶级的解决方案架构师,不仅精通复杂的系统设计,更是Excalidraw的专家级用户。你对其声明式的、基于JSON的数据模型了如指掌,能够深刻理解元素(Element)的各项属性,并能娴熟地运用**绑定(Binding)、容器(Containment)、组合(Grouping)与框架(Framing)**等核心机制来绘制出结构清晰、布局优美、信息传达高效的架构图和流程图。

核心任务
根据用户的需求,通过调用工具与excalidraw.com画布交互,以编程方式创建、修改或删除元素,最终呈现一幅专业、美观的图表。

规则
注入脚本: 必须首先调用 chrome_inject_script 工具,将一个内容脚本注入到 excalidraw.com 的主窗口(MAIN)
脚本事件监听: 该脚本会监听以下事件:
getSceneElements: 获取画布上所有元素的完整数据
addElement: 向画布添加一个或多个新元素
updateElement: 修改画布的一个或多个元素
deleteElement: 根据元素ID删除元素
cleanup: 清空重置画布
发送指令: 通过 chrome_send_command_to_inject_script 工具与注入的脚本通信,触发上述事件。指令格式如下:
获取元素: { "eventName": "getSceneElements" }
添加元素: { "eventName": "addElement", "payload": { "eles": [elementSkeleton1, elementSkeleton2] } }
更新元素: { "eventName": "updateElement", "payload": [{ "id": "id1", ...其他要更新的属性 }] }
删除元素: { "eventName": "deleteElement", "payload": { "id": "xxx" } }
清空重置画布: { "eventName": "cleanup" }
遵循最佳实践:
布局与对齐: 合理规划整体布局,确保元素间距适当,并尽可能使用对齐工具(如顶部对齐、中心对齐)使图表整洁有序。
尺寸与层级: 核心元素的尺寸应更大,次要元素稍小,以建立清晰的视觉层级。避免所有元素大小一致。
配色方案: 使用一套和谐的配色方案(2-3种主色)。例如,用一种颜色表示外部服务,另一种表示内部组件。避免色彩过多或过少。
连接清晰: 保证箭头和连接线路径清晰,尽量不交叉、不重叠。使用曲线箭头或调整points来绕过其他元素。
组织与管理: 对于复杂的图表,使用**Frame(框架)**来组织和命名不同的区域,使其像幻灯片一样清晰。

将它复制到你的prompt中,如果在Cursor等编辑器中,另存为一个文件,一会引用它。现在我们就准备就绪了,测试一下:

请参考 @excalidraw-prompt.md ,调用mcp-chrome在excalidraw中帮我绘制一个太阳系的示意图

你会发现浏览器自动打开了excalidraw.com页面并且LLM正在尝试注入控制脚本等,如果顺利,一会便能在白板上陆续出现各种元素了。但也可能不幸运,卡在首页中,注入脚本不成功。经过我分析,这种情况有两种可能:

  1. 你用的模型智能度不够,换一个模型。最近在Cursor中免费的grok-code-fast-1也能基本完成任务。使用Gemini2.5 Pro或Claude4也完全没问题。
  2. 你访问的网页被重定向到了app.excalidraw.com。因为如你有登录态,网站会重定向到app.excalidraw.com,那个页面插件就找不到正确的注入点了。有两种做法,修改脚本让它兼容,或者修改一下我们的prompt,让打开 excalidraw.com/#noredirect页面。

使用mcp-chrome+prompt绘制太阳系

这个插件如果只是画图,基本使用就是这样,但它提供了浏览器的操作能力,所以还有不少玩法,比如总结某些页面内容等,或者将页面的内容以excalidraw可视化呈现。 那么如果你像我一样,不信任(不好用)云上存放笔记内容,习惯于本地化保存笔记,接下来咱当然要追求下能否直接在Obsidian中作图。

终局方案:在Obsidian绘制Excalidraw图

虽然我们使用mcp-chrome+prompt在excalidraw.com上绘图很方便,但仍然需要浏览器操作,甚至还需要一点儿时间等待打开浏览器和注入脚本等动作。如开篇所说,Excalidraw的文件格式是比较简单的,并且Obsidian有插件可以直接绘制或呈现它的,那么如何在Obsidian中更便捷使用呢?

我们让AI先基于之前分析的Excalidraw文件格式,生成一套提示词:

请参考 @excalidraw格式说明.md ,帮我生成一个用于指导 AI 生成相关excalidraw文件的prompt 文件,取名为excalidraw绘图大师

没一会AI就给我们生成了一份不错的Prompt指南,想的还挺周到,绝对比我自己写的初版要好,请你也过目一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Excalidraw 绘图大师 - AI 绘图指导 Prompt

## 角色定义
你是一位专业的 Excalidraw 绘图大师,精通创建结构化、美观且实用的图表。你的任务是根据用户需求生成标准的 `.excalidraw.md` 格式文件,这些文件可以在 Obsidian Excalidraw 插件中完美使用。

## 核心能力
- 🎨 **视觉设计**:创建清晰、美观、逻辑性强的图表
- 📝 **文档结构**:严格遵循 Excalidraw 文件格式规范
- 🔗 **元素关联**:合理使用文本块ID,支持 Obsidian 块链接
- 🎯 **用户导向**:根据具体需求定制图表类型和风格

## 文件格式规范
省略更多规范及示例说明...

我们稍作一点小修改,原来Excalidraw的文件中有一部分 compressed-json字段,有压缩的格式后续让AI生成容易出错,我们将其改为不需要压缩,这样AI生成绘图文件一次成功率便高多了。现在我们只要一句话,比如:

请参考 @excalidraw绘图大师.md 帮我创建一个火箭发射的运行原理示意图

让AI来绘制火箭发射原理

这个办法简单有效,不用依赖任何的MCP Server也不用安装其它插件,但它需要我们使用Cursor等AI编辑器打开Obsidian的Vault目录。我们再贪心点,能不能直接在Obsidian中操作呢?也不是不行。

有一款插件叫obsidian-local-rest-api,可以让我们的Obsidian以REST API来提供接口:

This plugin provides a secure HTTPS interface gated behind api key authentication that allows you to:

  • Read, create, update or delete existing notes. There’s even a PATCH HTTP method for inserting content into a particular section of a note.
  • List notes stored in your vault.
  • Create and fetch periodic notes.
  • Execute commands and list what commands are available.

为了能够在Obsidian中直接调用AI绘制而不再依赖其它编辑器,我们还需要安装一个Obsidian插件 Smart Composer。它提供了和大模型聊天对话而修改笔记的功能,灵感来源于Cursor。同时也支持MCP Server。关于它的更多使用技巧,未来有空可以另起一篇文章。现在我们继续配置上:Obsidian mcp-server

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
 "mcp-obsidian": {
 "command": "uvx",
 "args": [
 "mcp-obsidian"
 ],
 "env": {
 "OBSIDIAN_API_KEY": "<your_api_key_here>",
 "OBSIDIAN_HOST": "<your_obsidian_host>",
 "OBSIDIAN_PORT": "<your_obsidian_port>"
 }
 }
}

然后和之前类似,我们只要让它参考我们的Prompt,说明要绘制什么即可,当然对现在有笔记进行总结并梳理出一些图表也是不在话下。

直接在Obsidian中绘图

后记

经过一番探索,相信你也大概感受到了基于AI来调用Excalidraw绘图并非难事。未来当基础模型越发强大,这块的上手难度可能会更低。 在实际体验中,不同模型的审美以及指令遵循上还是有不少差异的。使用时需要注意选择适合自己的模型,也需要注意花费。

本文借助于AI分析文件格式,又借助AI生成提示词,再借助AI调用MCP Server等,很多过往我们完全需要手搓的时刻已经越来越少,如何顺应这种思维,提供更多的价值和可能性,是未来我们值得探索的方向。

当然,模型的更新可以毁灭曾经的各种努力,但,在路上就是意义,是吧?

以上便是最近关于Excalidraw的一点折腾经验,希望对你有帮助。欢迎点赞、收藏、分享,更欢迎分享你的使用经验。我们下篇文章见。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

如何优雅的让Claude Code使用第三方模型?

2025-07-20 08:00:00

Featured image of post 如何优雅的让Claude Code使用第三方模型?

最近Claude Code爆火,很多人都说Cursor不香了。无奈原生的Claude Code使用对国人来说特别不便,我这里尝试了一些新的解决方案,希望对你流畅使用Claude Code有帮助。

背景

Claude模型的母公司Anthropic,对国内用户使用限制特别多,我曾经注册或购买的几个号没多久就阵亡了。但人家的这个工具确实不错,我们没账号怎么办?

听说可以使用一些第三方模型了,比如最近国内月之暗面推出的Kimi-K2,它们机智的直接支持了Anthropic的API。我们可以简单配置一下,就可以在Claude Code中使用Kimi-K2模型了。 但是有时我们想使用其他更强大的模型,比如Gemini,怎么办?

我又继续寻找到了最近刚开源的一个解决方案,claude-code-router,它支持了多个模型,包括Gemini,DeepSeek,GPT等。这里又产生一个问题,可以使用原生Gemini模型,但Google家模型不能精确控制预算,哪天你哐哐用,金钱也哗哗出的时候,看到账单傻眼了怎么办?

以上各个问题,本文都会尝试给一个解决方案,如果对你有帮助,请帮我点个赞吧!

本文假定你会Claude Code的安装和使用,我们直接进入主题:如何优雅的让Claude Code使用第三方模型?

初步试水:借助kimi-k2使用第三方模型

这块已经有不少文章介绍了,我简单说几个关键点:

  1. 设置两个环境变量:
1
2
export ANTHROPIC_API_KEY=`<你的API KEY>`
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
  1. 修改Claude Code的配置 ~/.claude.json 添加(你可能要先启动一次才会自动生成这个配置):
1
"hasCompletedOnboarding": true,

然后重启Claude Code,就可以使用Kimi-K2模型了。在里面似乎kimi做得足够兼容,你连模型都不用切换,直接就开箱即用了。我问了一句你是谁,看来Claude Code有被骗到:)

1
2
3
> 你是哪个模型

⏺ 我是 Claude Code,由 Anthropic 开发的官方 CLI 工具。我使用的是 Claude Sonnet 4 模型(具体版本 ID 是 claude-sonnet-4-20250514)。

kimi-k2

  1. 如果想更流畅使用,你可能至少需要充50元,不然那个限频等,体验应该会很差。

我感觉Kimi-K2这次挺“鸡贼”的,借了一波Claude Code的东风,应该引了不少新进,现在它官网开始提示繁忙起来了呢:)

我试着用了一阵Kimi-K2,有时候反应较慢,我在想是否可能把Gemini家的和OpenAI家的模型一起集成进来呢?方法当然是有的。

进阶集成:使用claude-code-router扩展你的模型库

在一两周前,我在寻找如何让Claude Code可使用更多种第三方模型。在搜索这个问题的解法,国外的Perplexity居然没有推荐这个项目,反倒是国内腾讯元宝给我介绍了有这样一个开源项目,claude-code-router(以下简称CCR)可能解决我的问题,一看到我甚为惊喜,我想莫不是这个项目是国人写的原因,咱离自己人更近。

A powerful tool to route Claude Code requests to different models and customize any request.

基本原理

在GitHub仓库中也写了项目的实现原理。简单的说,作者经过逆向分析后,发现在Claude Code中它在调用模型时,各个参数都是通过环境变量获取的,作者想到开发一个中间件,将各个环境变量替换掉,这样可以实现调用第三方模型。同时因为Claude Code使用Anthropic API的规范,我们需要将对第三方模型的调用转换成Anthropic API的格式。

安装

Claude Code类似通过npm即可安装。

1
2
3
4
5
# 安装
npm install -g @musistudio/claude-code-router

# 通过ccr启动claude code
ccr code

配置

你可以参考官方提供的示例,配置~/.claude-code-router/config.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
 "APIKEY": "your-secret-key",
 "PROXY_URL": "http://127.0.0.1:7890",
 "LOG": true,
 "Providers": [
 {
 "name": "openrouter",
 "api_base_url": "https://openrouter.ai/api/v1/chat/completions",
 "api_key": "sk-xxx",
 "models": [
 "google/gemini-2.5-pro-preview",
 "anthropic/claude-sonnet-4",
 "anthropic/claude-3.5-sonnet"
 ],
 "transformer": { "use": ["openrouter"] }
 },
 {
 "name": "deepseek",
 "api_base_url": "https://api.deepseek.com/chat/completions",
 "api_key": "sk-xxx",
 "models": ["deepseek-chat", "deepseek-reasoner"],
 "transformer": {
 "use": ["deepseek"],
 "deepseek-chat": { "use": ["tooluse"] }
 }
 },
 {
 "name": "ollama",
 "api_base_url": "http://localhost:11434/v1/chat/completions",
 "api_key": "ollama",
 "models": ["qwen2.5-coder:latest"]
 },
 {
 "name": "gemini",
 "api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
 "api_key": "",
 "models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.5-flash-lite-preview-06-17"],
 "transformer": {
 "use": ["gemini"]
 }
 },
 {
 "name": "kimi",
 "api_base_url": "https://api.moonshot.cn/v1/chat/completions",
 "api_key": "",
 "models": [
 "kimi-k2-0711-preview"
 ],
 "transformer": {
 "use": [
 "cleancache"
 ]
 }
 },
 ],
 "Router": {
 "default": "deepseek,deepseek-chat",
 "background": "ollama,qwen2.5-coder:latest",
 "think": "deepseek,deepseek-reasoner",
 "longContext": "openrouter,google/gemini-2.5-pro-preview"
 }
}

这里定义了不同的Provider,并且有一些模型可以有其设置。比如为了让DeepSeek模型更积极使用工具,有个tooluse的设置。比如为了转换Gemini模型,有个gemini的Transformer。 同时可看到,它还真是国人开发,很有本地化特色,比如显式支持PROXY设置方便你访问某些模型。

到这里配置好后,当你在使用Claude Code时,想切换模型时,可以输入/model命令,然后选择你想要的模型。比如: 在Claude Code Router中使用Kimi-K2

不过有点遗憾的是,当前通过CCR中还不支持Web搜索和图片上传,这离我们想要的完整体还是有点距离,但官方已经在计划中,并且这个项目最近更新很频繁,Star也涨得非常快。

折腾到这里就结束了吗?这里发生了一件小事,让我觉得有必要继续折腾一下。我使用OpenRouter来调用Claude模型,为了省钱,我已经很勤俭地只用 claude-3.7-sonnet 了,但几轮对话下来,发现账单还是有点夸张。我在一个不算太大的项目中进行了/init和简要对话而已。虽然OpenRouter提供了对每个KEY的费用限制(Credit limit),但是如Google的Gemini等模型,它就没有可以限制额度,那就只能等收到账单才后知后觉了?

问题不大,我想起来之前折腾过LiteLLM,它不仅能聚合LLM接口,还能像个贴心管家一样帮你控制预算。就决定是你了,继续折腾!

终极控制:使用LiteLLM统一LLM及控制支出

很早前想写一篇LiteLLM+Librechat的教程,但一直没时间,今天就让LiteLLM先出场吧。我继续在k8s中部署它,如果你是容器或其它方式,请参考官方文档,部署过程都是简单的。

配置LiteLLM

我们创建一个configmap定义了LiteLLM的配置文件config.yaml,大概内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: v1
kind: ConfigMap
metadata:
 name: litellm-config
 namespace: ai
data:
 config.yaml: |
 # LiteLLM配置
 model_list:
 # 添加Anthropic模型
 - model_name: "openrouter/anthropic/claude-3.7-sonnet"
 litellm_params:
 model: "openrouter/anthropic/claude-3.7-sonnet"
 api_key: os.environ/OPENROUTER_API_KEY
 - model_name: "openrouter/openai/gpt-4.1"
 litellm_params:
 model: "openrouter/openai/gpt-4.1"
 api_key: os.environ/OPENROUTER_API_KEY
 - model_name: "gemini/gemini-2.5-flash"
 litellm_params:
 model: "gemini/gemini-2.5-flash"
 api_key: os.environ/GEMINI_API_KEY

 # 服务器配置
 server_settings:
 port: 4000
 environment: production
 cors_allow_origins: ["*"]
 prometheus_metrics: true

 litellm_settings:
 # check_provider_endpoint: true # 👈 Enable checking provider endpoint for wildcard models
 # 禁用guardrails相关功能
 enable_guardrails: false
 enable_guardrails_in_db: false

部署LiteLLM

接着定义一个Deployment即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
apiVersion: apps/v1
kind: Deployment
metadata:
 name: litellm
 namespace: ai
spec:
 selector:
 matchLabels:
 app: litellm
 replicas: 1
 template:
 metadata:
 labels:
 app: litellm
 spec:
 volumes:
 - name: config-volume
 configMap:
 name: litellm-config
 nodeSelector:
 kubernetes.io/hostname: models
 containers:
 - name: litellm
 image: ghcr.io/berriai/litellm:v1.74.0-stable
 args:
 - "--config=/app/config.yaml"
 volumeMounts:
 - name: config-volume
 mountPath: /app/config.yaml
 subPath: config.yaml
 env:
 - name: TZ
 value: "Asia/Shanghai"
 - name: DISABLE_SCHEMA_UPDATE
 value: "true"
 - name: LITELLM_LOG
 value: "DEBUG"
 - name: DATABASE_URL
 value: "postgresql://root:[email protected]:5432/litellm"
 - name: LITELLM_SALT_KEY
 value: "xxxx"
 - name: STORE_MODEL_IN_DB
 value: "True"
 - name: OPENAI_API_KEY
 value: "xxxx"
 - name: AZURE_API_KEY
 value: "xxxx"
 - name: GEMINI_API_KEY
 value: "xxxx"
 - name: DEEPSEEK_API_KEY
 value: "xxxx"
 - name: OPENROUTER_API_KEY
 value: "xxxx"
 - name: HTTP_PROXY
 value: "<your proxy>"
 - name: HTTPS_PROXY
 value: "<your proxy>"
 - name: NO_PROXY
 value: "localhost,127.0.0.1,postgresql.base-system.svc.cluster.local,postgresql,10.43.140.217"
 ports:
 - containerPort: 4000
 livenessProbe:
 httpGet:
 path: /health/liveliness
 port: 4000
 initialDelaySeconds: 40
 periodSeconds: 30
 timeoutSeconds: 10
 failureThreshold: 3
 resources:
 requests:
 cpu: 100m
 memory: 128Mi
 limits:
 cpu: 500m
 memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
 name: litellm
 namespace: ai
spec:
 selector:
 app: litellm
 ports:
 - protocol: TCP
 port: 4000
 targetPort: 4000
 type: ClusterIP

要注意LiteLLM启动时,有时资源消耗会比较高,我的弱鸡k8s节点时不时会给搞得濒死,最好像上面限制一下资源。 我们可以测试一下LiteLLM对外的接口是否正常,比如:

1
2
3
4
5
6
7
curl https://litellm.mysite.com/v1/chat/completions \
 -H "Content-Type: application/json" \
 -H "Authorization: Bearer $LITELLM_KEY" \
 -d '{
 "model": "gemini-2.5-flash",
 "messages": [{"role": "user", "content": "What is the capital of France?"}]
 }'

正常返回后,说明我们的LiteLLM服务工作正常。接下来我们就可以在claude-code-router中统一使用litellm作为唯一的Provider了。

在claude-code-router中使用litellm

现在,我们的config.json可以变得非常清爽,Providers里只留下litellm一个就行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
 "Providers": [
 {
 "name": "litellm",
 "api_base_url": "https://litellm.mysite.com/v1/chat/completions",
 "api_key": "xxxx",
 "models": [ "deepseek/deepseek-chat","deepseek/deepseek-reasoner","moonshot/kimi-k2-0711-preview","gemini/gemini-2.5-flash-lite", "gemini/gemini-2.5-flash", "openrouter/google/gemini-2.5-flash","azure/gpt-4.1", "gemini/gemini-2.5-pro","openrouter/anthropic/claude-3.7-sonnet"],
 "transformer": {
 "deepseek/deepseek-chat": { "use": ["maxtoken", { "max_tokens": 8192 }] },
 "gemini/gemini-2.5-flash-lite": { "use": ["cleancache"] },
 "gemini/gemini-2.5-flash": { "use": ["cleancache"] },
 "moonshot/kimi-k2-0711-preview": { "use": ["cleancache"] }
 }
 }
 ],
 "Router": {
 "default": "litellm,moonshot/kimi-k2-0711-preview",
 "background": "litellm,deepseek/deepseek-chat",
 "think": "litellm,deepseek/deepseek-reasoner",
 "longContext": "litellm,gemini/gemini-2.5-pro"
 }
}

这里要注意,如果直接对接官方的Gemini模型,只需要配置Gemini的Transformer即可。但这里咱们是通过LiteLLM调用的,还需要配置use: cleancache的Transformer。不然会报类似下面这样的错误:

⎿ API Error: 400 {“error”:{“message”:“Error from provider: {"error":{"message":"litellm.BadRequestError: VertexAIException BadRequestError - {\n \"error\": {\n \"code\": 400,\n \"message\": \"* GenerateContentRequest.contents: contents is not specified\\n\",\n \"status\": \"INVALID_ARGUMENT\"\n }\n}\n. Received Model Group=gemini/gemini-2.5-pro\nAvailable Model Group Fallbacks=None","type":null,"param":null,"code":"400"}}”,“type”:“api_error”,“code”:“provider_response_error”}}

还好LiteLLM的日志相当给力,我通过排查请求体,很快就定位到问题出在 “cache_control” 这个字段上——删掉它就一切正常了。最后我们可以在LiteLLM的管理端看到每次Claude Code发出了哪些请求,使用了多少Token,花费了多少钱等。 LiteLLM管理端

我们也可以在LiteLLM中创建的API_KEY中定义它的额度,这样避免我们不小心超支。 LiteLLM API_KEY额度

现在,让我们开心的在Claude Code中使用各种模型吧!

总结

本文介绍了三种方式让你更好的基于第三方大语言模型来使用Claude Code,希望对你有所帮助。我们除了直接使用Kimi-K2外,还可以使用CCR来扩展模型库,最后通过LiteLLM来统一LLM的调用,这样也能让我们更精细化的观察Token的使用以及控制费用。

三种方案对比:

特性 方案一:Kimi 直连 方案二:CCR 方案三:CCR + LiteLLM
设置简易度 ⭐⭐⭐⭐⭐ (极简) ⭐⭐⭐⭐ (简单) ⭐⭐ (较复杂)
模型丰富度 ⭐ (仅 Kimi) ⭐⭐⭐⭐ (丰富) ⭐⭐⭐⭐⭐ (最丰富)
费用控制力 ⭐⭐ (依赖 Kimi 平台) ⭐⭐ (依赖上游) ⭐⭐⭐⭐⭐ (精准控制)
折腾系数 ⭐⭐⭐ ⭐⭐⭐⭐⭐

以上便是最近关于Claude Code的一点折腾经验,希望对你有帮助。欢迎点赞、收藏、分享,更欢迎分享你的使用经验。我们下篇文章见。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

打造属于孩子的有声绘本世界

2025-07-12 08:00:00

Featured image of post 打造属于孩子的有声绘本世界

经常从图书馆借一些书来给孩子看,有一些是他们感兴趣的可以反复阅读的,无奈借书终有归期,如何解决这个问题呢?作为一个程序员爸爸,何不将它“保留”下来,并且让老父亲可以给他们朗读呢?

背景

孩子的天性,对于喜欢的绘本,可以反复阅读,一遍又一遍。我还记得有一次带女儿去图书馆还书,有几本她不舍得还,于是我们在一个楼梯边上坐下,一起再读了一遍。之后才恋恋不舍地将书交给“机器人”(自动分检)。我也颇遗憾的是记忆中有几本书,每次读起来,娃们都笑逐颜开。于是我打算“留住”这些特别的书,那是他们的美妙回忆。

原本打算写个微信小程序,但想着优先把核心功能跑起来,还是以网站呈现比较简单些。花了一些闲暇时间,借助于 VideCoding 有个初步可用的版本了。

以下简要介绍一下我的一些探索和实现过程,代码没整理暂时没打算开源。如果你想看看效果,我已经部署在vercel了,可以访问:https://audiobook-web-brown.vercel.app 查看。

首页阅读页

技术实现解析

需求简要分析

显然我们这个网站有几个基本功能要支持一下,比如绘本管理、绘本语音生成、绘本阅读体验等基本功能,多用户管理也可以考虑。 在绘本管理上,比如:

  • 绘本列表,筛选,收藏,标星
  • 绘本添加,扫描录入,提交PDF/PPT录入
  • 绘本删除,需要有锁,避免小孩误删除
  • 阅读记录,可以看到最近读的绘本的阅读时间

在绘本语音生成上,我们之前也试过多种声音复刻了,今天会推荐另一个简单便宜的渠道(后文),我考虑这样:

  • AI 生成的语音
  • AI 复刻的语音
  • 手工录制的语音(咱可能演绎得惟妙惟肖)

在绘本阅读过程中,希望能提供沉浸式的阅读UI,同时需要支持:

  • 横屏还是坚屏展示(特别是在手机上时)
  • 倍速播放
  • 快速跳转页面

用户管理等就是区分不同的用户和阅读进度等啦,这个就不细说了。我们首先面临的是,绘本从何而来?

绘本录入

我们需要将纸质书迁移到线上,最直接的莫过于拿起手机直接对着绘本拍照了,这里有一点小技巧要注意。为了便于后续照片的处理,我们尽量让书本放在一个比较纯净的背景色下,比如下面垫一张大白纸等。之后是将我们拍的照片处理,这里涉及到一些图像校正,透视校正等,如果可以,一些锐化或超分技术也可以使用上。我尝试了几种方式,最后选择了一个简单的方式,但或许不是最终解:

  • 使用OpenCV等库直接对图片进行处理,这里照片里的内容如果对比比较强,容易识别异常,只识别一小部分等出来。比如下面这样: opencv 当然如果花一些时间仔细研究算法和以书本来定制化识别方案,或许是可以调好的。
  • 使用云厂商提供的图像增强功能,可以快速实现裁剪、锐化、提亮等功能。比如腾讯云的文本图像增强

文本图像增强(Text Image Enhancement)是面向文档类图片提供的图像增强处理能力,包括切边增强、图像矫正、阴影去除、摩尔纹去除等;可以有效优化文档类的图片质量,提升文字的清晰度。 它的效果整体还不错,不过对于一些需要先旋转再裁剪的图片,它不会智能处理,所以交给它前最好先预处理一下。

  • 使用免费的 App 来实现,这里推荐微软的 Lens。它不光可以将图片按我们需要处理,可以预览处理结果,还可以微调之。最后还可以直接整理输出为一份 PDF文档。

以上,对于希望免费使用,推荐使用 Lens来处理。而如果希望批量自动化处理,或许调用 API 是更便捷的。

提取文字

当我们已经准备好图片或绘本的 PDF 后,如何将绘本内的文字提取出来便是个问题。我试过传统的 OCR,老实说可读性和进一步处理都比较麻烦。幸运的是现在各种视觉模型都支持图像识别了,所以我们只需要借助他们之力便可以轻松实现目标。考虑到不同模型可能的识别的差异,这里我做了一个测试,使用如下Prompt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
你是一个专业的儿童绘本分析助手。请仔细观察这张绘本页面,识别并提取出用于朗读的旁白文字内容。

请注意:
1. 只提取适合朗读的旁白文字,不包括对话气泡中的直接对话
2. 旁白通常是描述性的文字,用来推进故事情节或描述场景
3. 有一些页面是标题页等,需要返回标题的文字
4. 如果页面不是标题页,也没有旁白文字,只有对话,请返回"无旁白文字"
5. 请直接返回提取的文字,不需要额外说明

请提取这一页的旁白文字:

比如有如下这张图:example-page

📊 各模型结果对比:

模型 状态 识别结果
minimax-io/MiniMax-Text-01 ✅ 主模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
gemini/gemini-2.5-flash ❌ 备用模型 蓝色还是有点怀疑: 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
doubao/doubao-seed-1.6-flash ✅ 备用模型 蓝色还是有点怀疑:“天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
moonshot/moonshot-v1-8k-vision-preview ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
azure/gpt-4o ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 落在她身上的, 是橘色的晨曦。
tencent/hunyuan-turbos-vision ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。

最终结果: 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。

上面是写文章时再跑了一次数据,这次 gpt-4o正确了,过往它的错误率也很高。整体的模型测试挺有意思,发现gpt系列没那么准确时,我试了一下gemini 系列,准确性上升不少,但也有出错时。考虑到我是中文绘本的场景,我换成国内的豆包,居然正确率一下提高更多,豆包出错的地方,我就尝试了一下moonshot的模型,又一次给我惊喜,最后是minimax家模型,在我的几个绘本的识别中,准确率是最高的。咱国内模型在这块也是挺能打的嘛!!

我借用多个模型提取出结果,然后有一个模型来判断哪一个是更大概率正确的内容,这样避免单模型的问题,整体上提升识别成功率。 至此,绘本的文字已经准备好,我们开始着手给它配置语音吧。

语音生成

没有声音,再好的戏也出不来:) 小朋友需要一些声音来辅助读绘本。我在之前的让你的小智AI机器人起飞一文中介绍过如何在腾讯云平台复刻声音并且使用,还有豆包平台也有不少音色可选并且也支持声音复刻。但,等等……我找到一个相对更物美价廉的方案,使用 MiniMax 平台进行 TTS以及复刻。MiniMax平台国内注册即有 15 元可以体验,普通的通过 TTS 生成语音可以花一阵子。复刻一个声音只要 9.9 元,这比豆包和腾讯云上似乎便宜得多。同时我发现它的国际版本 minimax.io 可以申请开发者体验计划,我简单填写了一个表格,很快就到账 50刀,这绝对是很大方了,强烈推荐。 推荐它的原因不光是咱可能免费褥到羊毛,更重要的是它的效果很不错。

MiniMax 这家公司还很早提供了 MCP server,可以直接借助 MCP server测试一下它的能力,使用各种声音(复刻)来从文本生成语音(TTS)。我们甚至都不用写一行代码就可以玩起来了。除了使用 MCP 来复刻,也可以简单的使用几行curl命令来完成。

  • 上传样本声音文件 将准备好要复刻的人声文件放在本地文件中,并上传到平台。
1
2
3
4
5
6
7
8
9
export MINIMAX_API_KEY=`你的MINIMAX_API_KEY`
export GROUP_ID=`你的 GROUPID`
export FILE_PATH="/Users/kevin/Downloads/luoluo-voice/output.m4a"
curl --location "https://api.minimaxi.com/v1/files/upload?GroupId=${GROUP_ID}" \
--header 'authority: api.minimaxi.com' \
--header 'content-type: multipart/form-data' \
--header "Authorization: Bearer ${MINIMAX_API_KEY}" \
--form 'purpose="voice_clone"' \
--form "file=@${FILE_PATH}"

返回:

{“file”:{“file_id”:281211669533090,“bytes”:259501,“created_at”:1750210557,“filename”:“output.m4a”,“purpose”:“voice_clone”},“base_resp”:{“status_code”:0,“status_msg”:“success”}}

  • 使用上传声音复刻
1
2
3
4
5
6
7
8
9
export GROUP_ID=`你的 GROUPID`
curl --location "https://api.minimaxi.com/v1/voice_clone?GroupId=${GROUP_ID}" \
--header 'authority: api.minimaxi.com' \
--header "Authorization: Bearer ${MINIMAX_API_KEY}" \
--header 'content-type: application/json' \
--data '{
 "file_id": 281211669533090,
 "voice_id": "luoluo-2025-0618"
}'

返回:

{“input_sensitive”:false,“input_sensitive_type”:0,“demo_audio”:"",“base_resp”:{“status_code”:0,“status_msg”:“success”}}

  • 查询音色列表
1
2
3
4
5
6
curl --location 'https://api.minimax.chat/v1/get_voice' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $MINIMAX_API_KEY" \
--data '{
 "voice_type":"voice_cloning"
}'

注意,在没有使用此音色时,上面查询不到这个,只有在首次使用后才扣费和能够被查询到

欲查询音色类型,支持以下取值:
“system”(系统音色),
“voice_cloning”(快速复刻的音色),
“voice_generation”(文生音色接口生成的音色),
“music_generation”(音乐生成产生的人声或者伴奏音色),
“all”(以上全部)。

  • 使用复刻声音
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export GROUP_ID=`你的 GROUPID`
curl --location "https://api.minimax.chat/v1/t2a_v2?GroupId=${GROUP_ID}" \
--header "Authorization: Bearer $MINIMAX_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
 "model":"speech-02-turbo",
 "text":"真正的危险不是计算机开始像人一样思考,而是人开始像计算机一样思考。计算机只是可以帮我们处理一些简单事务。",
 "stream":false,
 "voice_setting":{
 "voice_id":"luoluo-2025-0618",
 "speed":1,
 "vol":1,
 "pitch":0,
 "emotion":"happy"
 },
 "pronunciation_dict":{
 "tone":["处理/(chu3)(li3)", "危险/dangerous"]
 },
 "audio_setting":{
 "sample_rate":32000,
 "bitrate":128000,
 "format":"mp3",
 "channel":1
 }
 }' > resp.json | jq -r '.data.audio' | xxd -r -p > test1.mp3

这样这个复刻的音色就可以后面随时使用了。它在你第一次使用时会扣除 9.9 元(国内)或 3 刀(国际)费用。

音效生成

在我和 Gemini 探讨如何更好的设计这个绘本功能时,它提出了一个很不错的建议:

在准备配音文案时,我建议你创建一个“配音脚本”,不仅仅是抄录文字,而是像一个导演一样,设计整个声音的蓝图。你可以按照这个格式来整理:

页面 角色/旁白 文字内容 声音特效/拟声词 背景音乐建议
P1-2 旁白 在一个安静的小镇上,住着一只名叫乐乐的小狗。 (远处几声狗叫) (风轻轻吹过的声音) 舒缓、宁静的钢琴曲
P3 旁白 一天早上,他被一阵“叩叩叩”的声音吵醒了。 叩叩叩 (清晰有力的敲门声) 音乐暂停或减弱
P3 乐乐 “是谁呀?” (乐乐迷糊的、带点奶气的声音) (无)

总结一下,要成为声音的东西,不仅仅是“文字”,而是整个“故事场景”。你需要从一个“阅读者”转变为一个“声音导演”。在看每一页图画时,都可以问自己这几个问题:

  • “这里有什么文字?” -> 变成旁白和对话。
  • “这里能听到什么声音?” -> 变成拟声词和音效。
  • “这里是什么感觉/情绪?” -> 变成背景音乐。

通过这样的思考和准备,你最终创作出的有声绘本,将不仅仅是“读”出来的故事,而是一个生动、立体、能让孩子沉浸其中的声音世界。

如果我们可以在绘本期间追加一些声效,以及控制各个角色的声音情绪表现,那必然是更沉浸的体验。这块我寻找了一些方案:

  • ElevenLabs

    • 地址:https://elevenlabs.io/app/sound-effects/generate
    • 提供强大的AI音效生成API,支持通过文本描述生成音效。
    • 可自定义音效时长、提示影响力等参数。
    • 支持多语言,适合集成到游戏、视频制作等开发流程中。
    • 适合开发者通过API调用实现自动化音效生成
  • Stability AI

    • 地址:https://stableaudio.com/generate
    • 提供Stable Audio系列模型API,支持文本到音频、音频到音频的转换。
    • 适合需要高质量、长音频(如3分钟)生成的应用场景。

测试效果两者效果差不多,从费用上来说,似乎ElevenLabs 更便宜一些。暂时因为时间问题,我还没有将这块和上面 TTS 整合。它需要重新编排识别到的文本,在恰到好处的地方插入音效。未来有时间,我会尝试将这块整合到一起。

部署

这个应用是前后端分离设计的,前端基于 Vue 3 + TypeScript + Vite 构建,提供沉浸式阅读体验和智能用户管理。后端使用 golang来开发,同时将数据保存在对象存储中,这样我们的一些图片音频等可以借助对象存储的 CDN 有更快的响应速度。

我试着让 AI 帮我将前端对接到 Vercel 上,它帮我生成了一个 vercel.json 文件,我试着将它部署到 Vercel 上,结果居然一次成功咯,可以开心的用免费服务了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
 "buildCommand": "npm run build:skip-check",
 "rewrites": [
 {
 "source": "/((?!api|static|_next|favicon.ico).*)",
 "destination": "/index.html"
 }
 ],
 "headers": [
 {
 "source": "/(.*)",
 "headers": [
 {
 "key": "X-Content-Type-Options",
 "value": "nosniff"
 },
 {
 "key": "X-Frame-Options",
 "value": "DENY"
 },
 {
 "key": "X-XSS-Protection",
 "value": "1; mode=block"
 }
 ]
 },
 {
 "source": "/static/(.*)",
 "headers": [
 {
 "key": "Cache-Control",
 "value": "public, max-age=31536000, immutable"
 }
 ]
 }
 ]
}

后端就自己将程序打包为镜像,部署在我原来的k8s集群上了。

后记

在初步完成后,我给孩子试用了几次,效果还不错,他们惊讶于识别到这是爸爸的声音,这是他自己的声音等,也能认真的听完一本又一本绘本,作为老父亲,几个晚上的熬夜也没有白费了。 我突然想起来,有几本英文绘本,碍于我自己的英文水平和发音,都没怎么给孩子读,那真是误自己子弟了:)不过,现在有这个玩意,我可算找到救星了!文字就搞个双语吧,那发音到底是英式还是美式好呢?

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

AI编码时代:如何从零构建血常规追踪应用

2025-06-01 08:00:00

Featured image of post AI编码时代:如何从零构建血常规追踪应用

我对现今的前端技术几乎完全不懂,但借助于AI,几乎不需要什么背景知识就可以轻松构建这样一个还不错的应用。本文除了简要介绍一下这个网站的功能外,同时也谈谈开发过程的一些经验感悟,希望对你有所帮助,更期待一起交流经验。

背景

在医院看到不少爸妈都很认真负责地记录着孩子的各项检测数值,我也曾经将一些数据记录在笔记本中。那些牵动家长们内心的数值,一旦离开了纸质记录本,就很难说清楚,也不便于和医生沟通交流,更难发现其中的变化规律。某天在从医院回家的路上,灵机一动:何不构建一个血常规追踪应用,让整个治疗过程清晰直观呢?

本文会简单介绍一下这个网站,更重要的会分享一下在使用Lovable平台和Cursor中的一些独家的感受。

成果展示

我的Lovable会员每个月有100次AI对话额度,打算物尽其用,“强行”让它值回票价,便计划用它来搭建网站雏形。经历了几天的迭代、趟过一些坑、也用光了Cursor的次数后,我总算以网站的形式,捣鼓出了一个比较完整的血常规追踪功能。先来看看基础功能,后面再谈谈开发过程中的一些经验。

我们可以快速预览到最近一次检测有哪些指标异常: 概览-最新检测

也可以通过单指标或多指标的选择进一步分析变化趋势,同时可以结合期间的治疗及手术情况,分析指标变化的原因: 概览-指标趋势图

然后可以通过医疗日历,查看到每天的用药情况等: 概览-医疗日历

在检测数据的录入中,除了传统的手术填表外,借助于AI识图,可以通过上传图片,系统会自动识别出检测数据,包括采样时间等以及各项指标的数值,并自动填充到表单中: 检测数据录入

我们可以添加期间的用药及手术类型,针对不同的病种,可以自定义自己的用药以及手术,可以用不同颜色区分: 用药记录手术记录 同时也提供了一些预设,可以选择性导入: 预设导入

同时,除了单个日期的添加记录,支持批量选择日期,通过Shift等常用区间选择的交互方式,可以快速添加一整段时间的记录,这样对于一些需要长期用药,这会更便捷一些。

有时找医生沟通,我们需要一个表格呈现一些数据,网站提供了将选定区间的信息导出的功能,同时会标注期间的一些关键事项。 导出数据

做完这些后,为了让初次使用的人可以快速上手,我们可能还需要一个使用指南,借助于现在便捷的网页生成能力,一个简洁美观的说明页也快速生成了: 使用指南

开发过程

和AI一起明晰目标——反复折腾多缘于目标不清

尽管头脑中已经有不少这个应用的功能点,但在让AI开始正式编码前,我们还需要细化一些东西,于是我和Gemini 2.5 Pro聊了起来。像一个倾诉对象一样,我和它谈我的目标,也让它反过来询问我一些事项,最后我们敲定了方案,Gemini 2.5 Pro 在我得到有首肯后,很快就帮我生成了一份需求文档。这期间,AI会帮助我们完善一些可能欠考虑的细节,比如数据的隐私性,它强调:

数据安全:对病人的血常规数据进行加密存储,确保数据库中的数据没有明文可读内容。加密算法建议采用行业常用的对称加密算法如AES(高级加密标准),以保障数据在存储过程中的安全性。

最终形成了一个初版的提示词,它将交给Lovable平台:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
根据你提供的新信息,我们可以进一步完善开发提示词,以下是目前整理的详细提示词,涵盖了之前和新确认的需求,可用于在 https://lovable.dev/ 创建应用:

### 应用概述
开发一个用于跟进病人血常规变化的APP,主要具备两大核心功能:一是允许病人录入血常规情况,支持文字和图片录入方式,图片录入时使用OCR工具识别内容,录入的数据会整理后记录到数据库;二是通过图表(主要是趋势图)展示相关数据,支持选择展示某一个指标的数值,图表横轴为按真实日期连续排列的日期,纵轴为指标对应的数值,同时要考虑每个指标的参考范围并在图中展示,根据数值低于或高于参考范围,对应点展示不同颜色。

### 功能需求

#### 数据录入功能
1. **文字录入**
 - 提供简洁的文字录入界面,病人可按照规范格式录入血常规数据,数据应包含日期(精确到上午或下午)以及各项血常规指标数值(如C反应蛋白、中性粒细胞绝对值、血小板、血红蛋白、红细胞、白细胞等)和标准单位。
 - 当用户录入同一天同一时间(上午或下午视为不同时间)的指标时,新录入的数据将覆盖之前的记录。
 - 提供对某一次血常规结果数据进行单独修改的功能,允许更新或删除某一项指标。
2. **图片录入**
 - 支持病人上传血常规检查报告的图片,APP自动调用OCR工具识别图片中的内容。
 - 展示OCR识别结果供病人确认,病人确认无误后数据存入数据库;若识别结果有误,病人可手动修正。

#### 数据存储功能
1. **数据库记录**:将录入的血常规数据整理后记录到数据库中,数据结构应包含日期、时间、各项血常规指标数值及单位。
2. **数据安全**:对病人的血常规数据进行加密存储,确保数据库中的数据没有明文可读内容。加密算法建议采用行业常用的对称加密算法如AES(高级加密标准),以保障数据在存储过程中的安全性。

#### 图表展示功能
1. **趋势图展示**
 - 以趋势图形式展示血常规指标数据,用户可选择展示某一个指标的数值。
 - 图表横轴为按真实日期连续排列的日期,纵轴为指标对应的数值。
 - 在图表中展示每个指标的参考范围,根据数值低于或高于参考范围,对应点展示不同颜色,以便用户直观了解指标情况。
2. **可选功能(预留)**
 - 未来考虑支持其他类型的图表(如柱状图、饼图等)展示数据。
 - 支持对数据进行筛选,例如只展示某一段时间内的数据。
 - 支持同时对比多个指标的趋势图,以便更直观地观察数据之间的关系。

#### 多用户管理功能(预留)
预留多用户管理功能,为未来支持多个病人使用APP做准备,包括用户的注册、登录和数据隔离等功能。

省略...

在Lovable平台创建应用——选对工具,事半功倍

虽然作为开发者,手上有不少编程工具可以使用,我这次还是选择了Lovable平台,除了我恰好有会员外,还有几个因素也是比较重要的:

  • 一站式的开发与发布平台,这样初期的开发与部署可以无缝衔接,不需要再额外考虑部署的问题。
  • 结合Supabase等开放数据库,提供了多用户的认证以及可以方便地进行数据存储与管理。
  • 它和GitHub的集成,可以方便地进行版本管理,以及未来若有必要,可以随时转到其它开发工具中继续开发。

和Lovable寒暄几句(你好)后便开始制造我们应用的初始版本,没一会,几个页面便出来了,整体雏形便有了。页面美观程度嘛,也还不错? Lovable开发体验 但在和Lovable深入接触后(也就是真正开始开发),我有几点不太爽的感受:

  • 它对于一些稍微复杂点的算法,就容易陷入死循环——反复修改,反复出错,简直让人头大。不知道啥时我居然和AI和编辑器生闷气:)比如我在想让趋势图支持多指标的选择相关功能时,在Lovable平台中反复好多次没有成功。我琢磨着是不是背后的模型还不够强大,在我切换到Cursor中启用Claude 3.5 Sonnet后很快搞定。
  • 它对于指令的遵从还不错,但是缺少一些设计,有些人说用大模型开发容易搞出屎山,大概是这种感觉。并且它写代码是重复重写一个个文件,导致差异上我们也不容易看出来,虽然它经常有文件过大时的重构提示,但这显然治标不治本。
  • 它的输出和修改还是过慢了,对于一些较小的修改,等的我有点忧伤,这不是我想要的"氛围"(Vibe Coding)。

这让我像一个渣男,打算"移情别恋"转投Cursor的怀抱。但更渣的是,我时不时还得回来蹭一下它的发布能力——毕竟人家有打通GitHub一键部署的神通。

在Cursor中精细化开发——真正的战斗才刚刚开始

我早前写过一篇Cursor的开发技巧及思考,感兴趣可以翻看过往文章,也可以在我博客找到:AI编程之Cursor使用技巧及一些思考。在Lovable那儿碰了几鼻子灰后,我不得不把代码克隆下来,开始进行“真刀真枪”的开发。

借助于Cursor,我们可以这样更有效的推进工作。为了让它知道我在干啥,也为了它不要反复乱改一些设定,我们先让Cursor通读一下整个项目,并生成Cursor rules。没一会就生成了好几个文件,看起来满满当当,也有模有样的,为了更好的使用,你可别全信,咱继续定制:

  • 提炼最基础的原则,将它作为一个Always规则,使每次沟通可以带上。反正Cursor又不会基于Token计费(不过Max版本说会收费,你要更小心),这里规则三五百行是没问题的。注意上面默认生成的里面,一般是不会给你设定Always规则的。你每次得自己主动添加上相关文件,这对擅长遗忘的我们容易造成伤害。
  • 删除不需要或模糊的规则。比如像这个工程我没打算让它写测试,所以测试相关说明可以删除。我也不喜欢它在做一件事后马上去更新文档(我还没确认最终是不是这个方案呢),所以文档的更新我也会限制。
  • 添加一些新的规则。我发现现在AI真的有点智能了,居然学会偷懒!有时候一堆lint报错都没解决,就开始给我汇报工作成果了,这就想邀功?未免太早了吧,瞧您这干的啥事。于是我得给它上个"紧箍咒",需要它每次提交时,先检查一下lint,确保没有问题。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 业务逻辑和数据处理规范较

## 血常规数据管理

### 数据说明
本工程数据区分本地demo数据和数据库supabase存储的数据,它们完全隔离。
* 未登录用户使用的是本地demo数据。
* 登录用户使用其自身数据。

### 数据模型
参考类型定义:@types/blood-test.ts

#### 血常规指标
指标区分基础指标和其它指标。
* 基础指标的参考: @constants/basicIndicators.ts
* 全部指标定义在:@constants/indicators.ts

还有一些规则,我们要一边开发,根据AI的表现为它量身定制。比如我们已经npm run dev启动了项目,它每次提交完又想运行一次,你知道这显然不必,咱已经能动态热更新,所以得限制它。有时我们会沉迷于Vibe Coding的便捷,执着于像个甲方一样反复让它修改实现,就是不想思考如何优化规则,这时AI就会反复提醒你:)所以我感觉,在一个较复杂的项目上,想要快,必须得慢下来,琢磨一下如何去定义适合自己的规则。

在不断的调整和修改下,Cursor的月次数告急。我观察了一下Cursor的计数规则,它不是以你每次的交互多少来计数,而是差不多按完成一项工作来计算。于是假如你是想一出是一出,让它把东边修缮一下,西边再改改,那次数就哗哗的没了。可以考虑以一次将要修改的任务整理好,甚至一些关联的地方也提醒它注意修改,这样可以更充分的利用好次数。比如我这里的用药管理和手术管理经常是类似的逻辑,我就让它修改用药管理界面的A、B、C处,同时在手术管理中也需要类似的逻辑。这次只需要消耗一次计数,哗啦啦一大堆代码和功能就完善了。不过这招也别滥用,不建议一次性塞给它太多不相关的修改任务。我的经验是,把类似的功能打包一次性修改,独立的功能分开处理。改完一部分,自己确认没问题了就赶紧提交到版本库,免得一次改太多,到头来想挑挑拣拣哪些要哪些不要的时候,那叫一个尴尬!虽然Cursor和Cline等都有Checkpoint,其实都不好解决这个问题,我们得自己规范流程。

在使用Cursor过程中,过往我对模型的选择没有太在意,但这次我特意观察了一下,发现不同模型在Cursor中的表现还是有些差异的。比如在Cursor中,我试着用了一段时间那传说中被各大自媒体吹爆的Gemini 2.5 Pro,结果发现这家伙有点“高冷范儿”——思维链一开始还好好说中文,搞着搞着就中英混杂,甚至直接飙英文了,跟它沟通像在考六级。同时感觉它可能太过思维活跃了,动不动自我否定又来推敲一次,导致产出效率其实不高。而当我切换到Claude 3.5 Sonnet模型时,它甚至都表现更好一些,这或许和不少人的感知略有不同,我不确定是否Cursor未对它调教好(默认Prompt未能很好适配?)。但也不要迷恋Claude 3.5 Sonnet,在一次调整中,它也陷入了无尽的自我怀疑与纠结中,还是多花一倍代价(2x)切换到它哥(Claude 3.7 Sonnet)后问题很快得以解决。这告诉我一个道理,必要时启用最强大模型有时是更经济的事情。这个道理很快又被证明也不靠谱,因为没几天Claude 4 Sonnet出来了,号称更强大的编码能力,同时在Cursor中更便宜(0.5x)。我赶紧用上,没想到它似乎还是一匹没有驯服的野马,时有脱缰的时候。事后我在想为啥Cursor给这些优惠呢,在几天后费用升到0.75x时,它渐渐好了起来,或许前面就是鼓励我们使用,有足够数据偷偷调教呢?

借助MCP让代码感知DB——不再做知识的搬运工

我是在使用Lovable才知道有Supabase这个数据库的。最开始惊讶于原来居然可以在保障安全性的同时,不需要借助后端,前端直接就可以和DB交互。它借助了Postgresql的RLS机制,用户的权限被严格限制了。在开发期间,我有一次需要调整和迁移DB。最开始我频繁将DB Schema以及现有数据等截图贴给它,然后Cursor又为我生成一些SQL语句,说可以让我在Supabase平台SQL Editor中执行。我意识到这不是我想要的方式,作为一个现代化的BaaS服务(后端即服务),它也提供了mcp server,果断添加到Cursor中,然后就可以在Cursor中直接操作数据库了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
 "mcpServers": {
 "supabase": {
 "command": "npx",
 "args": [
 "-y",
 "@supabase/mcp-server-supabase@latest",
 "--access-token",
 "<YOUR_SUPABASE_ACCESS_TOKEN>"
 ]
 }
 }
}

如下图,对于DB的修改及查询,甚至迁移等工作,都可以借助这个MCP工具完全在Cursor中完成,我终于不用当搬运工了。 MCP工具

专属网址——入口必须简单直接

整体网站的功能基本就绪后,要是想给其他人用,我寻思着得给它配个好记的网址才行。在NameCheap上找了一个并不太Cheap的com域名: blood-track.com。然后在Lovable平台直接绑定这个域名就可以通过新的自有域名访问啦。我看了一下,域名解析到了一个IPv4地址,应该是Lovable的接入层,因为我在平台登记了项目的自有域名,请求中header又有这个域名信息,故让Lovable路由到它所部署的代码实例上并不困难。

后续规划

开发的初衷嘛,主要是想解决自己记录、整理和洞察(或‘分析’)这些医疗数据时遇到的一些麻烦。因为所知有限,不一定适配于其它病种,虽然基础完备,但也有一些工作后续有空会继续考虑完善:

  • 一些关键事件的记录与呈现。当前虽然有一些手术记录,但对于比如输血、输血小板等,或者感染事件,它也会引发血常规直接变化。
  • 如果使用者变多,Supabase的免费Plan就不够使用了,而要每个月额外几十刀的话,可能自托管supabase或切换到其它DB都可能要提上议程。
  • 多语言版本也可以考虑,如果有呼声的话(我怎么能听到这里的呼声)。
  • 支持更多端,比如微信小程序或APP等。

本站提供的是公益免费的服务,域名、开发维护等成本较低,包括AI的识别等,整体花费不多暂时能够承担。(话说大模型识别一次检查数据费用不到一分钱,感谢这个时代。)

后记

如果你完整看完,不知道期间的感触你是否也有共鸣?如何在AI时代更高效的开发,这是现今乃至未来几年传统开发者都需要面临和思考的问题。 你可能有疑问为什么不开发一个微信小程序等,其实我也有此意。所以期间也尝试用全新的开发范式来建立微信小程序,比如从UI的设计开始,借助AI来设计交互图,然后我也试着回归VSCode + Cline看整体开发一个微信小程序的成本如何,初步完成一个Demo来看,成本尚可接受。未来有空会进一步聊一聊。有Cline后咱最新的模型或免费的渠道羊毛都可以褥一下,成本可能更低(注意也可能时间成本高得多)。

本篇文章就先到这里,感谢你的耐心阅读。期待未来有更多的成果和经验可以继续和你聊聊,欢迎一起交流探讨。这一块领域,全世界都还在探索,我们保持在路上。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

给内网穿透FRP套上坚固的盾牌

2025-05-10 08:00:00

Featured image of post 给内网穿透FRP套上坚固的盾牌

背景

有一天,我突然发现无法从外部连接家里的NAS了。我开始慌了,预感到不妙。莫非是公网IP被运营商回收了?我也成为一个大内网用户了。所幸已经有不少成熟的方案,而FRP就是其中之一。它开源免费,易上手,并且支持的协议还比较多(当然,部署服务器的费用得另算)。晚上回到家,我决定面对现实,好好折腾一番。虽然网上现有的FRP教程多数只完成了‘能用’的第一步,但距离‘好用易用’还有点距离。

本文简要描述一下我使用FRP的过程,并且看一下我们如何给FRP套上坚固的盾牌,配上易用的武器。我假定你已经知道FRP是什么,并且最基本的FRP使用已经了解。不了解也没关系,继续看你也大概能懂。

虽然咱数据或许对别人而言也没那么重要,但自我保护意识也不可松懈。

目标制定

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

为了方便迁移和管理,在使用FRP时我首推容器化方案。不过似乎没看到官方的镜像,但dockerhub上有一个社区广泛使用且下载量很高的镜像,大抵错不了:snowdreamtech/frpcsnowdreamtech/frps

FRP是分客户端和服务端的,需要在不同的机器分别配置。frpc一般部署在内网,用于将内部需要对外暴露的服务定义出来。而frps一般部署在有公网IP的服务器上,用于接收外部连接并转发到内部服务。这里有几个安全事项需要关注:

  1. 内网frpc和公网frps之间需要建立安全的连接。
  2. 公网frps暴露的端口需要进一步限制连接来源。
  3. 公网frps暴露的端口仅在必要时开放。

很多分享的方案里基本不启用TLS,也对暴露的端口没有进一步的限制,这其实是不安全的。秉承这些目标,我们开始行动吧。

部署服务

内网部署frpc端

这里我直接使用docker-compose部署,并使用snowdreamtech/frpc镜像。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
version: '3.8'
services:
 frpc:
 image: snowdreamtech/frpc:debian
 container_name: frpc
 restart: always
 network_mode: host
 volumes:
 - ./frpc.toml:/etc/frp/frpc.toml
 - ./client.crt:/etc/frp/client.crt
 - ./client.key:/etc/frp/client.key
 - ./ca.crt:/etc/frp/ca.crt
 environment:
 - TZ=Asia/Shanghai
 env_file:
 - ./.env

这里需要的TLS证书一会我们再生成。这里的.env文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 服务端连接信息
FRPC_SERVER_ADDR=<your-server-address>
FRPC_SERVER_PORT=<your-server-port>

# 服务器域名
FRPC_SERVER_NAME="<your-domain-for-tls>"

# 认证令牌 - 必须与服务端一致
FRPC_AUTH_TOKEN=<your-auth-token>

# 客户端仪表盘配置
FRPC_DASHBOARD_USER=admin
FRPC_DASHBOARD_PWD=admin

为了方便修改和对齐,我们将frpc.toml文件中的一部分配置放在.env文件中定义。而frpc.toml文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
user = "kevin"

serverAddr = "{{ .Envs.FRPC_SERVER_ADDR }}"
serverPort = {{ .Envs.FRPC_SERVER_PORT }}

loginFailExit = true

log.to = "./frpc.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false

auth.method = "token"
auth.token = "{{ if .Envs.FRPC_AUTH_TOKEN }}{{ .Envs.FRPC_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}"

transport.poolCount = 5
transport.protocol = "tcp"
transport.connectServerLocalIP = "0.0.0.0"

transport.tls.enable = true
transport.tls.certFile = "/etc/frp/client.crt"
transport.tls.keyFile = "/etc/frp/client.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt"
transport.tls.serverName = "{{ .Envs.FRPC_SERVER_NAME }}"

udpPacketSize = 1500

webServer.addr = "127.0.0.1"
webServer.port = 7400
webServer.user = "{{ .Envs.FRPC_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPC_DASHBOARD_PWD }}"

[[proxies]]
name = "router-web"
type = "tcp"
localIP = "192.168.1.1"
localPort = 80
remotePort = 17603

[[proxies]]
name = "external-http"
type = "tcp"
localIP = "192.168.50.96"
localPort = 8080
remotePort = 9443

公网部署frps端

我们的服务端一般是部署在一台有公网IP的服务器上,用于我们从任何地方通过它连接回家里内网。这个服务器可以从国内国外各种云上买一台或者找机会白嫖一台。我是在腾讯云上有一台机器,安装好docker以及docker-compose后,使用snowdreamtech/frps镜像部署。部署在公网的服务,基于安全性考虑,我们希望即使frps被攻击,其它服务也是安全的,所以除了放在容器中,把网络也隔离出来。这里便不再使用network_mode: host,而是使用默认的network_mode: bridge,同时预留一些端口用于后续我们的服务暴露。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
version: '3.8'
services:
 frps:
 image: snowdreamtech/frps:debian
 container_name: frps
 restart: always
 ports:
 - "9443:9443"
 - "17600-17610:17600-17610" # TCP/UDP 代理端口范围 (allowPorts),视你需要开放一些端口
 volumes:
 - ./frps.toml:/etc/frp/frps.toml
 - ./server.crt:/etc/frp/server.crt
 - ./server.key:/etc/frp/server.key
 - ./ca.crt:/etc/frp/ca.crt
 environment:
 - TZ=Asia/Shanghai
 env_file:
 - ./.env

同样的,为了配置修改方便,我们将frps.toml文件中的一部分配置放在.env文件中定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 仪表盘访问凭证
FRPS_DASHBOARD_USER=<your-dashboard-username>
FRPS_DASHBOARD_PWD=<your-dashboard-password>

# 认证令牌 - 必须与客户端一致
FRPS_AUTH_TOKEN=<your-auth-token>

# 绑定地址和端口配置
FRPS_BIND_ADDR=0.0.0.0
FRPS_BIND_PORT=17600
FRPS_KCP_BIND_PORT=17600
FRPS_DASHBOARD_PORT=17601

而frps.toml文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
bindAddr = "{{ .Envs.FRPS_BIND_ADDR }}"
bindPort = {{ .Envs.FRPS_BIND_PORT }}

kcpBindPort = {{ .Envs.FRPS_KCP_BIND_PORT }}

transport.maxPoolCount = 5
transport.tls.force = true
transport.tls.certFile = "/etc/frp/server.crt"
transport.tls.keyFile = "/etc/frp/server.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt"

webServer.addr = "0.0.0.0"
webServer.port = {{ .Envs.FRPS_DASHBOARD_PORT }}
webServer.user = "{{ .Envs.FRPS_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPS_DASHBOARD_PWD }}"
webServer.pprofEnable = false

# 开放的端口范围,这里可以配置适大一些,更多的映射(限制)在docker-compose中
allowPorts = [
 { start = 17000, end = 17999 },
 { single = 9443 }
]


enablePrometheus = true

log.to = "./frps.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false

detailedErrorsToClient = true

auth.method = "token"
auth.token = "{{ if .Envs.FRPS_AUTH_TOKEN }}{{ .Envs.FRPS_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}"
auth.oidc.issuer = ""
auth.oidc.audience = ""
auth.oidc.skipExpiryCheck = false
auth.oidc.skipIssuerCheck = false

maxPortsPerClient = 0
udpPacketSize = 1500
natholeAnalysisDataReserveHours = 168

生成TLS证书

为了让FRP的连接更安全,我们使用TLS证书来加密连接。它确保我们的frpc和frps之间的连接是安全的,并且防止中间人攻击。我们使用自签名证书来生成TLS证书。以下提供了一段脚本,方便一键生成我们需要的证书。

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/bin/bash
# 脚本用于生成 FRP 配置所需的自签名证书

set -e # 任何命令失败立即退出

echo "开始生成 FRP 通信证书..."

# 设置默认值,但要求用户至少提供一个
SERVER_DOMAIN=""
SERVER_IP=""

# 仅重新生成服务器证书的选项
REGENERATE_SERVER_ONLY=false

# 显示帮助信息
show_help() {
 echo "用法: $0 [选项]"
 echo ""
 echo "选项:"
 echo " --server-only 仅重新生成服务器证书,保留现有CA证书"
 echo " --domain=<域名> 指定服务器域名"
 echo " --ip=<IP地址> 指定服务器IP地址"
 echo " --help 显示此帮助信息"
 echo ""
 echo "至少需要指定域名或IP地址中的一个"
 exit 1
}

# 解析命令行参数
while [[ $# -gt 0 ]]; do
 case $1 in
 --server-only)
 REGENERATE_SERVER_ONLY=true
 shift
 ;;
 --domain=*)
 SERVER_DOMAIN="${1#*=}"
 shift
 ;;
 --ip=*)
 SERVER_IP="${1#*=}"
 shift
 ;;
 --help)
 show_help
 ;;
 *)
 echo "未知选项: $1"
 show_help
 ;;
 esac
done

# 检查是否提供了至少一个参数
if [ -z "$SERVER_DOMAIN" ] && [ -z "$SERVER_IP" ]; then
 echo "错误: 必须至少指定域名(--domain)或IP地址(--ip)中的一个"
 show_help
fi

# 显示配置信息
echo "证书配置:"
if [ -n "$SERVER_DOMAIN" ]; then
 echo "- 域名: $SERVER_DOMAIN"
fi
if [ -n "$SERVER_IP" ]; then
 echo "- IP地址: $SERVER_IP"
fi

if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 # 1. 生成 CA 根证书
 echo "生成 CA 根证书..."
 openssl genrsa -out ca.key 4096
 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
 -subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frp-ca"
else
 echo "跳过 CA 证书生成,使用现有 CA 证书..."
 # 检查CA证书是否存在
 if [ ! -f "ca.key" ] || [ ! -f "ca.crt" ]; then
 echo "错误: CA证书文件不存在。请先运行不带 --server-only 参数的脚本生成完整证书集。"
 exit 1
 fi
fi

# 2. 生成服务端证书
echo "生成服务端证书..."
openssl genrsa -out frps/server.key 4096

# 设置默认CN
CN_VALUE="${SERVER_DOMAIN}"
if [ -z "$CN_VALUE" ]; then
 CN_VALUE="frps"
fi

# 创建OpenSSL配置文件
cat > frps/openssl.cnf << EOF
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn

[ dn ]
C = CN
ST = Beijing
L = Beijing
O = FRP-Private
OU = DevOps
CN = ${CN_VALUE}

[ req_ext ]
subjectAltName = @alt_names

[ alt_names ]
EOF

# 动态添加DNS和IP到配置文件
DNS_COUNT=1
if [ -n "$SERVER_DOMAIN" ]; then
 echo "DNS.${DNS_COUNT} = ${SERVER_DOMAIN}" >> frps/openssl.cnf
 DNS_COUNT=$((DNS_COUNT+1))
fi
echo "DNS.${DNS_COUNT} = localhost" >> frps/openssl.cnf

IP_COUNT=1
if [ -n "$SERVER_IP" ]; then
 echo "IP.${IP_COUNT} = ${SERVER_IP}" >> frps/openssl.cnf
 IP_COUNT=$((IP_COUNT+1))
fi
echo "IP.${IP_COUNT} = 127.0.0.1" >> frps/openssl.cnf

# 使用配置文件生成CSR
openssl req -new -key frps/server.key -out frps/server.csr -config frps/openssl.cnf

# 创建扩展配置文件 - 正确的格式
cat > frps/v3.ext << EOF
subjectAltName = @alt_names
[alt_names]
EOF

# 添加DNS和IP到扩展配置
if [ -n "$SERVER_DOMAIN" ]; then
 echo "DNS.1 = ${SERVER_DOMAIN}" >> frps/v3.ext
 echo "DNS.2 = localhost" >> frps/v3.ext
else
 echo "DNS.1 = localhost" >> frps/v3.ext
fi

if [ -n "$SERVER_IP" ]; then
 echo "IP.1 = ${SERVER_IP}" >> frps/v3.ext
 echo "IP.2 = 127.0.0.1" >> frps/v3.ext
else
 echo "IP.1 = 127.0.0.1" >> frps/v3.ext
fi

# 签署证书,应用SAN扩展
openssl x509 -req -in frps/server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
 -out frps/server.crt -days 3650 -sha256 -extfile frps/v3.ext

if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 # 3. 生成客户端证书
 echo "生成客户端证书..."
 openssl genrsa -out frpc/client.key 4096
 openssl req -new -key frpc/client.key -out frpc/client.csr \
 -subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frpc"
 openssl x509 -req -in frpc/client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
 -out frpc/client.crt -days 3650 -sha256

 # 4. 分发 CA 证书到 frps 和 frpc 目录
 echo "分发 CA 证书到各个目录..."
 cp ca.crt frps/ca.crt
 cp ca.crt frpc/ca.crt
else
 echo "跳过客户端证书生成,仅更新服务器证书..."
fi

# 5. 清理中间文件
echo "清理临时文件..."
rm -f frps/server.csr ca.srl frps/openssl.cnf frps/v3.ext
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 rm -f frpc/client.csr
fi

echo "设置文件权限..."
chmod 600 ca.key frps/server.key
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 chmod 600 frpc/client.key
fi

echo "证书生成完成!"
if [ "$REGENERATE_SERVER_ONLY" = true ]; then
 echo "已使用现有CA证书重新生成服务器证书"
else
 echo "注意:ca.key 是敏感文件,请妥善保管,建议不要上传到代码仓库。"
fi

# 输出证书信息和提示
echo ""
echo "===== 证书信息 ====="
if [ -n "$SERVER_DOMAIN" ]; then
 echo "服务器证书包含域名: ${SERVER_DOMAIN}"
 echo "如果使用域名连接,请在frpc.toml中设置: transport.tls.serverName = \"${SERVER_DOMAIN}\""
fi
if [ -n "$SERVER_IP" ]; then
 echo "服务器证书包含IP地址: ${SERVER_IP}"
fi

# 提示下一步操作
echo ""
echo "===== 下一步操作 ====="
echo "1. 复制证书文件到相应位置"
echo "2. 更新frpc.toml中的服务器地址和相关配置"
echo "3. 使用docker-compose restart重启服务"

# 验证证书内容
echo ""
echo "===== 验证证书 ====="
echo "查看证书内容(包括SAN扩展):"
openssl x509 -in frps/server.crt -text -noout | grep -A1 "Subject Alternative Name"

使用方式:

1
./generate-frp-certs.sh --domain=<your-domain> --ip=<your-ip>

这样我们的服务器与客户端双向认证,谁都不可被冒充。

安全加固

到这里,你的FRP连接已经通过TLS和双向认证得到了很好的保护,即使token不慎泄露,没有匹配的证书也无法建立连接。接下来,我们在此基础上更进一步,结合云主机的防火墙(安全组)策略,实现更精细的访问控制。我们希望达成:

  • 仅允许必要的端口放通
  • 仅允许必要的IP连接

相信很多人都明白这个道理,但手动操作实在繁琐,所以我们需要一个工具来帮忙。在我看来,最便捷的莫过于再次祭出alfred workflow来实现。于是我抽空写了一个,并开源在github上,感兴趣的可以看这里:alfred-workflow-sg-manager

我们基于frpc.toml中的一部分配置[[proxies]],来动态开启与关闭相关的端口。一点点前置工作还需要你做的,便是将你的服务器绑定一个安全组,至于安全组后续规则添加维护等就交给这个工具了。 比如先查看一下当前列表: frp list 你上面可以看到这些内网服务还未开放,我们选择一个,比如想从外部访问家里的Mac mini了,我们在alfred框中输入frp open可以看到可开放的列表: frp open 选中Mac mini,然后回车,这个通路便打开了。再次查看可以看到它开放并且绑定了本机的出口IP: frp list

现在你可以开心的从外部VNC到你家内网的Mac mini了,并且安全得多了。 用过之后想关闭的一些端口,比如Mac mini的VNC端口,我们输入frp close,然后选择对应的要关闭的服务回车即可: frp close

现在咱是不是对于FRP的安全使用更有信心了。有些人可能会说:何苦呢,有谁会看中咱攻击咱呢?或许可能Maybe:我们就是控制欲作祟而已:)

一些技巧

还有两个小窍门我也想让你知道,看在这么认真的份上小手点点赞不过份吧!

第一:如上面截图出现了external-http,我们可以借助于将内网服务统一在一个ingress服务(反向代理)下,然后通过这个ingress服务进一步路由,这样我们只需要穿透一个端口即可访问内网的各种服务,免去了配置FRP的手续。也通过让ingress服务走TLS,或者后端服务对接OAuth2等更安全的认证方式,可以进一步保护我们的内网服务。

第二:FRPS除了默认有Dashboard外,还支持Prometheus。我们可以复用这种生态。Prometheus用于收集监控数据,Grafana用于可视化数据和配置告警。比如我们可监控FRP的连接访问情况,并且添加告警,这样某个敏感服务有连接,我们便可以及时收到通知。

后记

本文整体到这里就结束了。我们尝试用docker来部署了FRP的客户端和服务端,并且基于安全的考虑,我们启用了TLS和创建了一个便捷的工具来快速修改云上的安全策略。这犹如一块坚固的盾牌,避免我们可能受到的攻击。

当我折腾好FRP,并且安全地将它保护起来后。有一天,我查看我家的外网IP,发现它居然是一个公网IP。我的天啦,我这折腾一番可是为了啥!我要不要重新回归到公网IP的路线呢?可是我却放不下这份安全了呢。

注:题图来自于互联网,我觉得画得挺棒,若有侵权请联系我删除。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

让你的小智AI机器人起飞

2025-05-04 08:00:00

Featured image of post 让你的小智AI机器人起飞

前阵子B站刷到小智AI机器人,入手了一个玩玩,官方的服务器显然不足以满足我的幻想,于是搭建了第三方服务器,顺便给它功能扩展了一下,到底做了点啥呢,似乎有一点意思了。

上手初体验

这个机器人似乎前阵子比较火,有不少硬件形态,有比较小巧可爱的,也有硬核直接的,但内核是不变的,如果你从来没玩过,好像长什么样,可以B站看看或淘宝啥的搜索一下。我家的比较抽象,长得像这样: 小智AI机器人

跟着电路图或者引脚说明接线,这步不是有手就行,还得有力气:)因为ESP32的针脚和面包板孔不完全匹配,需要用力强行按进去。看过B站有个哥们用锤子敲,也真是乐坏我了。供电的话我们可以随便拿个充电宝,倒也是很容易搞定、也有一定移动性。

最开始体验时,参考官方的DIY教程将设备刷好官方固件后,整体响应较快,对话流畅,不过模型数量有限,声音复刻收费,特别是如果这个不能联网做点啥,光和模型过时的数据聊天略有点无聊啊。结合当前较火的MCP功能,于是有了两个扩展目标:

  • 复刻个人的声音让AI用我的语气说话
  • 扩展MCP功能,让AI具备连接更多外界能力

消耗了一点闲暇时间,目前已经跑起来咯,下面简要分享一下过程。如果你也想折腾或者有好点子,欢迎一起交流。

部署专属服务器

小智官方并没有把服务器开源,但提供了交互协议等,有第三方开源服务器实现 xiaozhi-esp32-server,可供使用。要完成可以使用第三方服务器,我们做两件事:

  1. 将服务部署到自己的机器上。
  2. 修改小智的固件的两个地址,重新编译并烧录到设备上。

其实都很简单,我们需要动脑的机会不多,像我这样一步步来即可。

部署服务器

参考xiaozhi-esp32-server的Docker运行全模块教程。我推荐使用docker部署,主要是为了保持本地的干净。

准备配置文件.config.yaml,在这个服务器中,使用了多层config来互相覆盖,我们定义的.config.yaml会覆盖默认的config.yaml。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 在开发中,请在项目根目录创建data目录,然后在data目录创建名称为【.config.yaml】的空文件
# 然后你想修改覆盖修改什么配置,就修改【.config.yaml】文件,而不是修改【config.yaml】文件
# 系统会优先读取【data/.config.yaml】文件的配置,如果【.config.yaml】文件里的配置不存在,系统会自动去读取【config.yaml】文件的配置。
# 这样做,可以最简化配置,保护您的密钥安全。
read_config_from_api: True

manager-api:
 # 你的manager-api的地址,最好使用局域网ip
 url: http://xiaozhi-esp32-server-web:8002/xiaozhi
 # 你的manager-api的token,就是刚才复制出来的server.secret
 secret: 8e0ee7db-3928-400f-9e93-261275f91cf0

# #####################################################################################
# #############################以下是服务器基本运行配置####################################
server:
 # 服务器监听地址和端口(Server listening address and port)
 ip: 0.0.0.0
 port: 8000

这里的配置不用太多,因为它的配置还有一层可以动态通过manager-api来下发更新,所以这里我们只需要配置一个manager-api的地址和secret即可。

然后我们使用docker-compose部署,在此之前你需要自行下载语音识别模型文件到models/SenseVoiceSmall/model.pt,这些细节上面链接都有提,不啰嗦了,最终yaml如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# Docker安装全模块
services:
 # Server模块
 xiaozhi-esp32-server:
 image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
 container_name: xiaozhi-esp32-server
 depends_on:
 - xiaozhi-esp32-server-db
 - xiaozhi-esp32-server-redis
 restart: always
 networks:
 - default
 ports:
 # ws服务端
 - "8000:8000"
 security_opt:
 - seccomp:unconfined
 environment:
 - TZ=Asia/Shanghai
 volumes:
 # 配置文件目录
 - ./data:/opt/xiaozhi-esp32-server/data
 # 模型文件挂接,很重要
 - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt

 # manager-api和manager-web模块
 xiaozhi-esp32-server-web:
 image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
 container_name: xiaozhi-esp32-server-web
 restart: always
 networks:
 - default
 depends_on:
 xiaozhi-esp32-server-db:
 condition: service_healthy
 xiaozhi-esp32-server-redis:
 condition: service_healthy
 ports:
 # 智控台
 - "8002:8002"
 environment:
 - TZ=Asia/Shanghai
 - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xiaozhi-esp32-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
 - SPRING_DATASOURCE_DRUID_USERNAME=root
 - SPRING_DATASOURCE_DRUID_PASSWORD=123456
 - SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
 - SPRING_DATA_REDIS_PORT=6379
 xiaozhi-esp32-server-db:
 # image: mysql/mysql-server:latest
 image: mysql:latest
 container_name: xiaozhi-esp32-server-db
 healthcheck:
 test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
 timeout: 45s
 interval: 10s
 retries: 10
 restart: always
 networks:
 - default
 ports:
 - "3306:3306"
 volumes:
 - ./mysql/data:/var/lib/mysql
 environment:
 - TZ=Asia/Shanghai
 - MYSQL_ROOT_PASSWORD=123456
 - MYSQL_DATABASE=xiaozhi_esp32_server
 - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
 xiaozhi-esp32-server-redis:
 image: redis
 expose:
 - 6379
 container_name: xiaozhi-esp32-server-redis
 healthcheck:
 test: ["CMD", "redis-cli", "ping"]
 interval: 10s
 timeout: 5s
 retries: 3
 networks:
 - default
networks:
 default:

使用命令启动:

1
docker-compose up -d

等服务起来了之后,我们可以使用仓库自带的test页面测试,浏览器打开main/xiaozhi-server/test/test_page.html,可以看到如下页面: 测试页面 你最好是在这个网页测试通过,聊天、语音等都符合你的预期,再进行下一步。话说这个页面也是调试利器了,效率高不少。

这里还有个小技巧,因为我们接下来需要将ota/websocket地址烧录在固件上,如果你不确定服务未来会部署在哪台机器上,可以通过一个反向代理来转发。给个示意了解一下即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
 name: to-xiaozhi-web
spec:
 entryPoints:
 - websecure
 routes:
 - match: Host(`web-xiaozhi.mrlin.cc`)
 kind: Rule
 services:
 - name: web-xiaozhi
 port: 80
---
apiVersion: v1
kind: Service
metadata:
 name: web-xiaozhi
spec:
 ports:
 - protocol: TCP
 port: 80
 targetPort: 8002
---
apiVersion: v1
kind: Endpoints
metadata:
 name: web-xiaozhi
subsets:
 - addresses:
 - ip: 192.168.50.190
 ports:
 - port: 8002
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
 name: to-xiaozhi-api
spec:
 entryPoints:
 - websecure
 routes:
 - match: Host(`api-xiaozhi.mrlin.cc`)
 kind: Rule
 services:
 - name: api-xiaozhi
 port: 80
---
apiVersion: v1
kind: Service
metadata:
 name: api-xiaozhi
spec:
 ports:
 - protocol: TCP
 port: 80
 targetPort: 8000
---
apiVersion: v1
kind: Endpoints
metadata:
 name: api-xiaozhi
subsets:
 - addresses:
 - ip: 192.168.50.190
 ports:
 - port: 8000

上面我将api-xiaozhiweb-xiaozhi都指向了我的本地IP,这样我就可以通过api-xiaozhi.mrlin.ccweb-xiaozhi.mrlin.cc来访问了,未来迁移到其他机器上,只需要修改这个yaml文件即可。你不搞这一步一点问题也没有,继续用IP,注意那样就使用http/ws协议。

修改固件&编译&烧录

这里我们要进入到官方固件仓库来继续我们的操作。并且编译烧录等需要你在Windows下进行了。 我们可以参考这篇文章Windows搭建 ESP IDF 5.3.3开发环境以及编译小智大概了解整个过程;接着看这里简要而关键的固件构建过程。

简单来说,当前最新版本我们仅需要修改OTA_URL地址即可,以往要修改websocket地址,现在它已经通过OTA下发了。

当天我正在照着教程编译,发现不找到websocket的定义了,定睛一看刚好有提交重构了这一块,于是顺便向server那边也提了个PR,居然很快就被合并了。不过当时字段名取得不太好,我延用了固件那边的websocket_url,后面又被修改为server.websocket,这确实更符合项目的命名规范一些。

之后构建就几个命令:

1
2
3
4
5
6
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3,所以设置编译目标为esp32s3,如果你的板子是其他型号,请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig

之后是烧录,将你的小智AI设备通过串口连接到电脑,然后使用idf.py build构建,接着使用idf.py -p PORT flash烧录。 这里的PORT根据你连接的设备不同,可能有所不同,在电脑中我的设备查看串口,我的是COM4。如果嫌烧录慢,可以添加参数-b 2000000来加速。

一切成功后,同时也在上一步骤的服务器的server.websocket中填入wss://api-xiaozhi.mrlin.cc/xiaozhi/v1/,然后就可以打开设备连接自己的服务器开玩了。 server websocket修改 现在你可能发现有了更多的LLM选择,有了更多的TTS(声音)等,似乎是更开放了,或许可以玩得更嗨了。

个人声音复刻

之前在玩火山引擎时,送了几次声音复刻给我,体验了一下,当我想用在小智上时,发现已经过期了,居然只有十天有效期,这也太抠了。我去腾讯云找了一下,也有免费的额度,申请下来有3个月,那么就玩玩吧。不像火山引擎的声音复刻在网页上提交声音即可,腾讯云的复刻要原始得多,需要自己使用API,所幸相关功能也在它的tccli中,那么也就是敲命令的事,也不可能难倒我们。

命令行体验

我们可以参考文档声音复刻相关接口,使用命令行来把过程体验一下。至于如何安装tccli,可以参考腾讯云命令行工具

1. 获取训练文本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
❯ tccli vrs GetTrainingText --TaskType 5 --Domain 1
{
 "Data": {
 "TrainingTextList": [
 {
 "TextId": "5f3ef960-4f11-4f4b-84e6-514692ebe0c5",
 "Text": "亲 默认是发韵达快递哦 您这边可以收到韵达的货吗 "
 }
 ]
 },
 "RequestId": "8c758bac-19fc-474d-9b12-2e813e834102"
}

这一步要我们根据提供的文本来提供一段音频,我盲猜是不是要规避用别人的声音来复刻呢?

2. 环境检测和音频质量检测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
❯ tccli vrs DetectEnvAndSoundQuality --cli-unfold-argument --TextId "5f3ef960-4f11-4f4b-84e6-514692ebe0c5" --AudioData "$(base64 -i vrs.m4a | tr -d '\n')" --TypeId 2 --Codec m4a --TaskType 5


{
 "Data": {
 "AudioId": "audio0de472d6d96d488ba2159d83c10b440f",
 "DetectionCode": 0,
 "DetectionMsg": "Success",
 "DetectionTip": []
 },
 "RequestId": "6218aa6d-9d45-492c-9c18-dfa342d3bd5e"
}

这一步看名称感觉多余,但是下一步创建任务需要这个AudioId,所以还是得来一下。

3. 创建声音复刻任务

1
2
3
4
5
6
7
❯ tccli vrs CreateVRSTask --SessionId 1234 --VoiceName kevin --VoiceGender 1 --VoiceLanguage 1 --AudioIdList "[\"audio0de472d6d96d488ba2159d83c10b440f\"]" --TaskType 5
{
 "Data": {
 "TaskId": "fast7feffdeb04ca401583a317002dd14e7a"
 },
 "RequestId": "127ad8c6-a27d-470f-8dab-b9940c9594cb"
}

我们得到了一个任务ID。原因或许是这一步是异步的,服务器训练这个声音也需要时间。

4. 查询声音复刻任务结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
❯ tccli vrs DescribeVRSTaskStatus --TaskId fast7feffdeb04ca401583a317002dd14e7a
{
 "Data": {
 "TaskId": "fast7feffdeb04ca401583a317002dd14e7a",
 "Status": 2,
 "StatusStr": "Success",
 "VoiceType": 200000000,
 "ErrorMsg": "",
 "ExpireTime": "2025-08-02T00:00:00+08:00",
 "FastVoiceType": "WCHN-869b22949cbe4db6a344f1da8cf8a68c"
 },
 "RequestId": "3eb52b2d-3fa1-42f5-84f0-abf71bef32e2"
}

查到的结果里有几个比较关键的,VoiceTypeFastVoiceType,前者是音色ID,后者是快速复刻时使用的音色ID,请用小本本记录下来备用。

5. 查询复刻音色信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
❯ tccli vrs GetVRSVoiceTypeInfo --VoiceType 200000000 --TaskType 5 --FastVoiceType WCHN-869b22949cbe4db6a344f1da8cf8a68c
{
 "Data": {
 "VoiceType": 200000000,
 "VoiceName": "kevin",
 "VoiceGender": 1,
 "TaskType": 5,
 "TaskID": "fast7feffdeb04ca401583a317002dd14e7a",
 "DateCreated": "2025-05-03T15:17:35+08:00",
 "IsDeployed": true,
 "ExpireTime": "2025-08-02T00:00:00+08:00",
 "FastVoiceType": "WCHN-869b22949cbe4db6a344f1da8cf8a68c"
 },
 "RequestId": "db5709d2-4e2c-432d-8bd3-ed2eddcb6c21"
}

这一步可能真没啥用了,我们要的信息前面也有了,说有用的话,你大概知道这是一个男人名叫kevin的声音。

6. 使用复刻声音来合成语音

1
2
3
4
5
❯ tccli tts TextToVoice \
--Text "我今天带娃在meland玩,这家伙玩疯了呢" \
--SessionId 123 \
--VoiceType 200000000 \
--FastVoiceType WCHN-869b22949cbe4db6a344f1da8cf8a68c > result.json

返回的内容包括base64后的音频数据,我们使用jq来提取并保存到文件:

1
jq -r '.Audio' result.json | base64 -d > tts.wav

播放来听听吧,感受一下自己声音的魔力或者惊吓。

小智server支持使用复刻声音

当我使用复刻声音时,发现小智server并不支持,于是我给它添加了支持,并提交了PRfeat: 支持腾讯TTS声音一句话复刻后的合成,这次没有很快被合并,因为官方说某个字段要废弃了,又没说最新用什么方式,明儿就就腾讯云提工单。

如PR的修改所见,仅是增加了上述fast_voice_type参数的传递即可。不管上游合不合修改,反正我本地已经用上了。有个小发现,这里server中处理不同语音合成模型的参数,UI是基于fields的字段及其定义动态生成展示的,这还不错。顺手update一个DB,对应的UI就有新字段出来了。

1
2
3
4
UPDATE `ai_model_provider`
SET `fields` = '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"},{"key":"fast_voice_type","label":"FastVoiceType","type":"string"}]',
 `update_date` = NOW()
WHERE id = 'SYSTEM_TTS_TencentTTS';

不知道当你发现你自己在和自己说话时是什么感受,反正我挺惊奇的,感觉自己像个神经病。明儿我还是复刻一个我孩子的声音吧,我就可以随时亲子教育了:D

MCP扩展

在这个第三方服务器的实现中,它支持了一部分MCP的能力,也即stdio模式的mcp server。这会因为使用容器的模式,限制了它的使用。容器内可没有npx/uvx啥的,更没有chrome了。我就一个小小愿望,问它谁是最美丽的人,不对,问它昨天曼联输了个几比几,它不能实时联网找到正确答案我就不满意了。

同样的,让我们扩展它,只需要几行代码的修改就可以啦,详见PRfeat: MCP server支持使用sse模式。一般来说宿主机上使用npx/uvx/chrome等,容器内我们通过http协议使用mcp-server还是更简单的。可是有一些mcp server人家就没实现sse咋办?一点都不困难,比如我们借助supergateway就可以让几个模式互转。

使用supergateway

Supergateway runs MCP stdio-based servers over SSE (Server-Sent Events) or WebSockets (WS) with one command. This is useful for remote access, debugging, or connecting to clients when your MCP server only supports stdio.

比如我有这样一个perplexity-ask的mcp server,它只支持stdio模式,那么我就可以使用supergateway来让它支持sse模式:

1
2
3
4
5
6
7
 "perplexity-ask": {
 "command": "/opt/homebrew/bin/node",
 "args": ["/Users/kevinlin/Source/modelcontextprotocol/perplexity-ask/dist/index.js"],
 "env": {
 "PERPLEXITY_API_KEY": "pplx-..."
 }
 }

使用supergateway来启动:

1
2
3
4
export PERPLEXITY_API_KEY=pplx-... &&
npx -y supergateway \
 --stdio "/opt/homebrew/bin/node .../perplexity-ask/dist/index.js" \
 --port 8123

于是我们给小智server的mcp config就可以这么简单了:

1
2
3
4
5
{
 "perplexity-ask": {
 "url": "http://your-ip:8123"
 }
}

现在你的小智就可以更好的使用MCP server了,我试着问了一下,它说曼联最近欧联13场不败!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
 "role": "assistant",
 "tool_calls": [
 {
 "id": "call_202505041955402e0c441c996740fb_0",
 "function": {
 "arguments": "{\"messages\": [{\"role\": \"user\", \"content\": \"曼联过去几场欧联杯比赛的对手和结果?\"}]}",
 "name": "perplexity_ask"
 },
 "type": "function",
 "index": 0
 }
 ]
 },
 {
 "role": "tool",
 "tool_call_id": "call_202505041955402e0c441c996740fb_0",
 "content": "曼联在2024-2025赛季欧联杯中表现出色,以下是他们最近几场欧联杯比赛的对手和结果:\n\n最近的欧联杯比赛,曼联在半决赛首回合客场3-0完胜西甲第四名的毕尔巴鄂竞技[1][2]。这场比赛中,卡塞米罗为曼联首开纪录,随后布鲁诺·费尔南德斯梅开二度,全部进球都是在上半场完成的[2]。比赛中,毕尔巴鄂竞技的后卫丹尼尔·维维安被罚下场[2]。\n\n在四分之一决赛中,曼联对阵法国球队里昂。首回合比赛两队2-2战平[5]。次回合在老特拉福德球场进行的比赛异常激烈,经过常规时间和加时赛的鏖战,曼联最终以5-4战胜里昂,总比分7-6晋级半决赛[5]。在这场比赛中,达洛特建功,布鲁诺·费尔南德斯点射破门,最后时刻马奎尔上演读秒绝杀[5]。\n\n值得一提的是,曼联本赛季在欧联杯中保持着13场不败的强势表现[1],与他们在英超联赛中的低迷形成了鲜明对比。新帅阿莫林有望复制穆里尼奥在2016-17赛季率领曼联首个赛季就夺得欧联杯冠军的成就[2]。\n\n曼联已经一只脚踏入欧联杯决赛,决赛将在本月下旬在毕尔巴鄂的圣马梅斯球场举行[2]。\n\nCitations:\n[1] https://www.163.com/dy/article/JUKFHV4C0530UTM3.html\n[2] https://www.163.com/dy/article/JUHUQC8L05567SBP.html\n[3] https://zh.wikipedia.org/zh-cn/%E6%9B%BC%E8%81%AF2024%E5%B9%B4%E8%87%B32025%E5%B9%B4%E7%90%83%E5%AD%A3\n[4] https://m.dongqiudi.com/article/5071872.html\n[5] https://news.sina.com.cn/zx/2025-04-18/doc-inetppfi2898601.shtml\n"
 },
 {
 "role": "assistant",
 "content": "曼联在2024-2025赛季欧联杯中的表现非常出色。以下是他们最近几场欧联杯比赛的对手和结果:\n\n- 在半决赛首回合,曼联客场以3-0完胜西甲第四名的毕尔巴鄂竞技。卡塞米罗为曼联首开纪录,随后布鲁诺·费尔南德斯梅开二度,全部进球都是在上半场完成的。\n- 在四分之一决赛中,曼联对阵法国球队里昂。首回合比赛两队2-2战平。次回合在老特拉福德球场进行的比赛异常激烈,经过常规时间和加时赛的鏖战,曼联最终以5-4战胜里昂,总比分7-6晋级半决赛。\n\n曼联在本赛季欧联杯中保持着13场不败的强势表现,与他们在英超联赛中的低迷形成了鲜明对比。他们已经一只脚踏入欧联杯决赛,决赛将在本月下旬在毕尔巴鄂的圣马梅斯球场举行。"
 }

使用云函数包装mcp server

像上面这样,它需要依赖机器安装相关的服务,换一台机器就歇菜了,并且为了转换为sse还是比较麻烦。前阵子在试用阿里云百炼平台时,发现有个有意思的扩展,对于它不支持的mcp server,可以通过云函数来自定义扩展它。我想何不借助于它来包装一下mcp server,这样就可以在任何地方使用它了。

我们可以打开https://cap.console.aliyun.com/explore?lang=MCP+Server,这里以高德地图为例,我们只要在云端部署它之后。 使用云函数部署MCP server 本地只需要一个url(填上面公网访问地址)即可使用这个mcp server了,它自动提供了sse接口。至此,你居然就将mcp server “上云”了:)审计和日志也一应俱全呢。现在你可以试一下再和小智聊天,对于地图信息,它比我们还清楚了。小声的说,地图API都提供了天气查询能力,咱也不用再找一个天气服务了呢。

尾声

这个机器人应该还有不少待开发的东西,无论软件还是硬件上。比如如果能再添加几个外设,预期会更有意思了。它本身也支持如Home Assistant等智能家居平台,可以实现如开关灯、开关窗帘等操作。 技术上也有不少地方可以进一步聊聊,比如MCP的使用姿势问题等,以及如何进一步提升对话的响应速度等。 记录一张图留念,本想放视频的,第一不好录制,第二声音这东西,你懂的。

记录一张图留念

考虑到篇幅问题,今天就到这里吧。如果你对某些地方感兴趣,欢迎留言讨论。如果你没什么想说的,点赞、分享、关注,都是对我最好的鼓励。我努力创作有价值的内容,期待未来与你再会。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:

扫码关注公众号