普通视图

发现新文章,点击刷新页面。
昨天以前钟意博客

国家统计局数据查询 MCP

作者 钟意
2026年4月5日 11:30

前言

今天凭借兴趣做一些数据分析,需要频繁查阅中国国家统计局的数据。每次都要打开官网、找到对应分类、下载数据、清洗整理,实在有点烦。于是顺手写了个 MCP Server,把国家统计局的公开 API 封装了一下,现在可以直接在 AI 对话里查数据了。顺手也水个博客吧, 诶嘿~

免费调用

free online mcp
1
2
3
4
5
6
7
8
{
"mcpServers": {
"cnbs": {
"type": "streamable_http",
"url": "https://mcp.api-inference.modelscope.net/c2ca6ece4e9946/mcp"
}
}
}
  • 命令行调用
    cmd
    1
    2
    3
    4
    5
    6
    7
    8
    {
    "mcpServers": {
    "cnbs": {
    "command": "npx",
    "args": ["mcp-cnbs"]
    }
    }
    }

示例问题

  1. 人口结构分析: 基于近五年中国人口出生率、死亡率和自然增长率数据,分析人口结构变化趋势,并对 2030 年人口结构进行预测。
  2. 区域经济对比: 对比北京、上海、广东、江苏、浙江五省市的 GDP 增长率、第三产业占比和人均可支配收入,分析区域经济发展差异。
  3. 通胀压力评估: 结合 CPI、PPI 和核心 CPI 的月度数据,评估当前通胀压力水平,并分析上下游价格传导机制。
  4. 消费结构演变: 分析近十年居民消费支出结构变化,重点关注教育文化娱乐、医疗保健、居住支出的占比演变趋势。
  5. 产业结构转型: 基于三次产业对 GDP 增长的贡献率和拉动数据,量化分析中国产业结构转型升级进程。

工具列表

其实下面的是给 AI 看的

数据查询

工具功能
cnbs_search关键词搜索,返回最新数据值
cnbs_fetch_nodes获取分类树节点
cnbs_fetch_metrics获取数据集的指标列表
cnbs_fetch_series获取时间序列数据
cnbs_fetch_end_nodes递归获取所有叶子节点
cnbs_batch_search批量搜索多个关键词
cnbs_compare数据对比(地区对比/时间对比)

参考数据

工具功能
cnbs_get_regions获取地区代码列表
cnbs_get_categories获取所有分类信息

辅助功能

工具功能
cnbs_get_guide获取使用指南
cnbs_get_cache_stats获取缓存统计
cnbs_format_number格式化数字
cnbs_transform_unit单位转换
cnbs_compute_stats计算统计信息

数据声明

本工具数据来源于中国国家统计局公开数据接口,更新至 2025 年。部分数据为初步统计值,后续可能根据经济普查结果修订。分析结论仅供参考,不构成投资或政策建议。

数据来源 :

照见·硅·肆 涌现之前

作者 钟意
2026年3月15日 20:00

文章源于学生的一个问题“新时代里我能干什么?”

大语言模型的涌现能力是在其规模与训练积累跨过阈值后,首次表现出此前未被显式训练、也难以线性外推的能力。俗称量变产生质变,**”More is Different”**。

人类的成长逻辑,恰好也是类似的“先积累,后涌现。”
遇千面人,读万卷书,行万里路,是积累学识,也是在见识中反复践行并校准学识与现实之间的偏差。大量学习与核实后,其两者的交集会涌现并逐渐固化为此人的方法论,凝练出三观。司空见惯,见怪不怪何尝不是一种训练。三十而立,四十不惑何尝不是结果。

机器的涌现靠算力和语料,人类的涌现靠经历、校准和自我追问;问题在于,智能正在提前抽走自我涌现所需的时间。
普鲁士式教育本就是把分工明确的社会需求预先写进训练流程,向社会输送具备基本定向能力的新鲜血液。我不能武断地说现代教育没有发散性,但至少可以说,它并没有给学生留下足够的时间与空间去真正发散,反而还在借助智能加速、加量地灌输旧知识。这是其一,当今教育下学生本就没多少精力去积累。
况且智能以「周」为单位迭代并转译,当其结果作用到我们的日常后,波及已经输送到社会的人时,此前的大部分普鲁士式教育成果会被替代——机械的工作流程、重复的文本处理、枯燥的无情客服等等那些传统的社会分工需求。失业者甚至找不到“实在不行就去流水线”这类备选项。学识与见识产生大量偏差后,难免滋生出虚无主义。这是其二,智能之后自我涌现的时机被前置压缩。

所以,在自我涌现之前,在虚无主义开始蔓延之前,如何先一步找到所谓存在的意义,并亲手去践行?又或者人如何在漫长的生命中抵抗虚无主义?
问题的答案,或许你亲口对晚辈说过,或许听长辈说过:去找到与现实建立连接的方式,去找到你愿意长期为之忍受痛苦的,甘愿反复投入的事情。

过去的人,终归还能在低价值高密度的重复劳动里慢慢长出手感。先处理那些机械、枯燥、甚至无意义的工作,再在一次次重复里缓口气去学会判断轻重、承担责任、识别人性、理解现实。那些工作无高低贵贱但都可以用重复形容,却曾是许多人完成自我涌现的前置。旧时代的人,是先劳动,再理解劳动的意义,再自我涌现。
但今天,机器与智能正在接管重复劳动,人未被真正解放,智能普惠还未确立,就把人置于一种一无是处的状态;更多时候,他们只是被提前抛进了一个必须回答“你是谁”“你想干什么”“你劳动价值在哪里”的空场里。问题被前置了,成长却没有被同步补课,社会也没有给出答案。新时代的人,或许必须先追问劳动的意义,才能决定自己要把一生的力气放到哪里。

已经输送到社会的人,更多只能在旧秩序松动时承受它坍塌的重量;他们要还房贷,要养家,要在身份已经定型之后,被迫接受“原来自己熟练的一切正在贬值”这种迟来的审判。阵痛会先落在他们身上,这是时代转轨的代价。先不讨论他们的问题,因为对他们而言,问题已经从“如何转型”滑向了“如何活着”。
学生不一样,不是因为他们更聪明,也不是因为他们更接近风口,只是“世界归根结底是你们的”,他们还有机会在被彻底定价之前,先决定自己要成为什么样的人。普鲁士式教育给大多数人的路径,是先把人训练成一枚合格的齿轮,再在漫长的运转中,偶然长出一点自我。新时代未必还有这样的缓冲了。既然重复劳动、机械流程和低阶试错正在被快速吞没,那么教育真正该提前的,也许不是更早职业化,而是更早人格化。

认识若不从实践里来,终究只是悬空的认识;人若不在现实里被反复校准,也很难长出真正属于自己的方法论。未来真正不容易被机器拿走的,未必只是某一项技能,而是你和现实建立连接的方式。机器可以写出答案,却不能替你经历答案生效后的余震;可以替你审核合同,却不能替你在一次失败的合作里识别人性;可以替你完成流程,却不能替你在漫长的反馈里修正自己的傲慢、怯懦与偏执。人最终留下来的,不只是能力,更是被现实反复雕刻后的形状。你独一无二,并且你得反复的明白你独一无二。
说得再直白一点,未来的年轻人得更早去准备承受自由。过去很多人并不真的知道自己想要什么,只是社会替他们预设好了轨道,于是他们顺着走下去,也能在运转中获得某种稳定的意义——比如活着本身。可当技术进步把旧轨道一点点拆掉之后,人迟早要面对更赤裸的问题——如果没有人替你定义价值,你是否还能自己定义自己?

这才是“涌现之前”真正残酷的地方。机器在参数足够时自然涌现,人却未必会在时间流逝中自动成熟。没有了足够的重复劳动,没有了足够缓慢的社会缓冲,没有了那种“先做着再说”的生存借口,人反而要更早面对自己。不是更晚,而是更早;不是更轻松,而是更困难。人的社会实践,不限于生产活动一种形式,还有多种其它的形式,但生产活动这种形式必定会减少。

可这未必是坏事。

人类终究没能靠一场集体觉醒挣脱枷锁,很多时候,是技术进步悄悄釜底抽薪,把旧秩序赖以咬合的经济基础一点点掏空,枷锁才不得不松动。可松绑之后,人至少终于有机会去问:我到底想把自己交给什么?我愿意用一生去反复磨损的,究竟是什么?

如果说旧时代的人,是先被世界需要,再慢慢理解自己;那么新时代的人,或许必须先理解自己,才知道该如何被世界需要。

在不知道能干什么后,我的答案是至少我们还剩下选择。
选择把力气用在何处,选择与谁同行,选择为何忍耐,选择为何创造。
也许这些答案依然模糊,依然稚嫩,依然经不起几次现实的风吹。
但那没关系。
先成为一个能被真实世界触碰的人,再去谈怎样成为一个有用的人。

“你能干什么”,被原封不动地砸回了人类。
这是肆号记录。
求索继续。

照见·硅·叁 换行重启

作者 钟意
2026年3月15日 00:00

有些代码,写完就要重构。
有些路,走到头才知道是死胡同。
有些人,亲手构建了替代自己的工具。
因为技术无情,预制菜不会因为不健康而停止迭代,技术的发展不为人的抗拒而停止,历史的进程不会因保守而不进取。

2024 年,我构建的 Agent 核心逻辑是让自然语言替代程序查询。当我写下第一行代码的时候,已经预感到这件事的递归性:正在用代码,消解代码的必要性。

这不是悲剧,这是工具的本质。锤子从来不在乎它钉下的那颗钉子是否会反过来封死自己的出路。但握锤子的人不同。
我想起两个人。一个是 Antirez,那个拥有代码洁癖的人,选择在 AI 浪潮前放下刀——不是被打败,而是选择在他认为有意义的地方停下。一个是三十年程序员,在技术分享结束后说”突然不知道自己是谁了”,眼神是认真的。
我介于两者之间,还没到放刀的时候,但已经感受到刀柄开始变轻——只是当下没有地方让我另起一行。

技术的另一面,是它确实在拉低某些本不可及的门槛,通过智能辅助稀缺经验重新定义消费水平,进而扩大本该发生的消费需求,是很好的落地实践。AI 辅助诊断影像报告压缩时间,那个本来没钱治病却本该得到治疗的人,终于可以走进诊室。法律特权的平民化、贵族式私教的普惠化、定制化软件的下沉等等,都是同样的逻辑在每一个曾经被价格筑起高墙的地方悄悄发生。
在更远一点的实验室里,AI 也在啃动那些世代难题。DeepMind 的 AlphaFold 基本解开了困扰结构生物学半个世纪的蛋白折叠问题。ETH 这类团队则用生成式模型,从蛋白的三维表面直接反推可能有效的小分子药物,和 Roche 等药企实测下来,确实能在一开始就给出稳定、低毒的候选分子。另一边,微软和国家实验室用 AI 在几千万种化学配方里筛固态电解质,从算力到实验室合成和测试不到九个月,把原本可能要二十年的材料发现周期压缩成了一个项目周期。

这些成果离日常生活还有几层转译,但至少证明了那条世界线确实在往上拧,只是拧的同时,摩擦下来的尘埃也在落在我们这代人身上。墙在降,但尘埃也在涌。能感测到世界线是螺旋向上的,但我已经被时代的尘埃压得喘不过气来,变通甚至是换行是必要的。

在编程里,\n 是换行符。它的作用不是结束,是另起一行。

我知道,把一切都看得很清楚,很容易变成不做选择的借口。
是去做更贴近底层基建的工程,还是更贴近人的教育与创作;是成为为 Agent 写工具的人,还是干脆把更多精力给诗歌和音乐。
我不确定下一行写什么。但光标还在闪烁,这就够了。当然别忘记享受当下,多多交流。

That the powerful play goes on, and you may contribute a verse.

换行不是结束,是另起一行。
这是叁号记录。
求索继续。

照见·硅·贰 钢筋水泥

作者 钟意
2026年3月14日 19:00

敲下回车,获得答案,这是我得到的现实。
无数晶体管因我而闪烁,无数能量湮灭,这是现实得到的现实。
屏幕里,Agent 正用极其拟人的口吻对我的 Prompt 嘘寒问暖,这是现实。
屏幕外,远方的冷却塔正向天空吐出滚滚白烟,这是现实;美国东海岸对周边千万居民发出的轮流停电通知,这是现实;孟菲斯居民对马斯克的燃气轮机疯狂排污抗议这也是现实。

我们更像是在炼丹,用特高压电网烧出几句拟人的 Token。
荒诞的,也是智能的。

电能经过算法转化为智能,并析出些许废料。这是粗犷但朴实的现阶段生产智能的描述,所以电能上限决定智能上限这是可以确定的。实现一切智能构想的前提是解决基础约束,服务于模型的电力、冷却、硬件诉求是智能向现实的映射。

恰好能源问题与废料事件后,黄仁勋优雅地向世界端出了一块五层蛋糕给这些焦头烂额的巨头品鉴。但扒开蛋糕的奶油,里面依旧全是重工业的机油味,恰似呼吁农场主一起把蒸汽机从河边搬到城市。 马斯克一针见血地戳破了无限算力的滤镜:“先缺芯片,再缺降压变压器,最后缺电。”仔细想想,这极具黑色幽默。全人类寄予厚望的、号称要解答“42”的硅基大脑,其最致命的瓶颈卡在法拉第 1831 年发明的铜线圈上。

但至少证明了全力推开硅基世界大门的过程中,现实会产生大量的岗位需求。当下迫在眉睫的底层蛋糕铺垫就需要电力建设、土地建设、液冷建设、网络建设等等五花八门的工人。给这阵痛期提供些许慰藉,虽然底层建设通常伴随着泡沫。
只是这些岗位,未必为我这样写代码的人预留座位。

目光望向最新的“十五五”规划的宏大叙事中,这种物理规律的引力被直白地写成了四个字:“算电协同”。至少在这一个时代的技术栈里,胜负首先落在谁能把更厚的重工业打包到机房里。这意味着,这场智能角逐的尽头,根本不是代码写得更优雅、算法更精妙,而是谁能圈下更多的光伏阵列、大坝与风电场。算力的尽头是电力,代码的尽头是钢筋水泥。

代码的尽头,果然是钢筋水泥。
这是贰号记录。
求索继续。

照见·硅·壹 你好世界

作者 钟意
2026年3月14日 14:00

你好,LLM 世界。 2023年3月14日。恰好三年前,GPT-4 发布的那一天,我在 ChatGPT 里留下了第一个问题:你能干什么?它的回答很朴素:文学创作、问题解答、编程辅助。
那一年,我也确实只是把它当作一个写作助手与结伴编程的工具。它引用的论文常常并不存在,给出的代码也时常无法运行。幻觉与错误几乎是常态。但即便如此,它仍然实打实地帮助了当时还是计算机学子的我。那时我意识到一种新的编程伙伴已经出现。

随后,AI 不再只是少数公司的实验室项目。
开源社区迅速介入,技术体系开始形成:

  • RAG 让模型能够连接真实世界的数据
  • LLaMA 引爆开源模型革命
  • vLLM 让推理基础设施逐渐成熟
  • MoE 让更大规模模型成为可能
    AI 开始从“模型能力”走向“系统工程”。

你好,Agent 世界。 不断涌现的技术体系让行业意识到模型不等于产品,而这个行业天生擅长整合,Agent体系诞生。融合知识库、图谱、工具链与记忆机制的 Agent 平台开始出现,并向尚未被 AI 冲击的行业展示新的生产方式。AI 不再只是回答问题的程序,而开始成为一种可以工作的系统。

你好,AI 世界。 2024年底入行,我第一个任务是构建Agent完成对一切数据的整合,以实现自然语言完成此前的程序查询、数学预测、非结构化知识提取等工作,并且赋予此前没有的数据发散联想关联能力。
2025年初中文领域推理模型DeepSeekR1开源,对刚刚进入这个领域的开发公司来说无异于雪中送炭。借着开源的东风我部署它正式推开AI世界的大门。

三年时间,AI 从一个会写错代码的聊天机器人,已经变成重塑软件工程生产方式的基础设施,正在变成与能源、电力并肩的国家级底层基础设施,可以看到硅基世界的大门隐隐松动。
而我,也从一个旁观的学生,变成了参与者。
回头看,那句简单的问题——
“你能干什么?”
也许正是我进入硅时代的第一步。
回想起来,我或许真正想说的是:
你好,硅基世界。

“你能干什么”,已经变成了另一个问题。
这是壹号记录。
求索继续。

照见·硅·零 序言

作者 钟意
2026年3月14日 10:00

在计算机科学中,数组总是从零开始索引,这样就有图灵在二进制中保佑代码。作为记录时代变革的序章,我想在这里确立一个原点,厘清一个初衷:在技术日新月异的今天,我为什么要写下《照见·硅》?

触发我动笔的,或许是看到拥有代码洁癖的编程艺术家 Antirez 放下手中刀的投降;或许是看到面对 AI 浪潮“突然不知道自己是谁”的三十年老程序员时的共情;又或者是过去三年里,我从一个旁观的计算机学子,转变为亲自下场入局者时,所经历的巨大重构。
我想更主要的原因是手工编程正在从软件生产的默认方式,退化为少数高价值场景中的特种工艺,哪怕连衡量“进步”的标尺本身都在不断被推翻
而艺术在我创作的过程中会反哺改变我,我只是想永远保持思考保持创作,仅此而已。

当机器开始掌握逻辑、生成代码、甚至推演未来,那些曾被我们引以为傲、视为“手艺人护城河”的技能,正在被不可思议的速度拉平、甚至反超。在硅基文明狂飙突进的轰鸣声中,身为碳基生命的我很容易产生一种失重感——机器已经接管了“构建”的动作,那握着鼠标的人,其工作的价值究竟在哪?如果机器已经揣摩出需求的下一步,那敲下键盘的人,其不可替代的灵魂究竟在哪?

这就是《照见·硅》系列诞生的原因。

“照见”是一个双向透视的动作:

  • 一层向外,我试图凝视这股正在重塑所有行业的技术洪流,记录它的迭代、疯狂与潜能。
  • 一层向内,我试图把智能进程当作一面巨大的时代之镜。我想借这面镜子求索,审视自身的认知、追求、劳动价值以及自我认同,是如何被它解构,又是如何被重新塑造的。

这不是一份干涩的技术教程,也不是追赶风口的新闻简报,而是一份长达数年或数十年的个人“思维快照”。那些原以为需要人类漫长适应期的变革,现在正以「周」为单位迭代。在这场注定颠覆一切的巨变中,我不想仅仅做一个顺流而下的被动承受者,我渴望留下一点清醒的、带有体温的碳基观察。

坐标原点已确立。
这是零号记录。
求索开始。

大语言模型的不确定性

作者 钟意
2026年2月7日 10:00

temperature=0、seed=0 也不等于完全确定, 工程实践的过程中总会有取舍, 请允许理想与现实的偏差.

大佬的镇楼图

导读

温度=0 + 种子固定,最多是“更接近 deterministic”,而不是“数学意义的 deterministic”。介不就是光速与绝对零度嘛。

在真实工程环境下,即使做到:

  • temperature = 0
  • 随机种子(seed)固定为 0
  • 模型权重不变
  • 输入 Prompt 字节级一致

LLM 输出依然无法保证严格的确定性。 原因来自多个层级:采样配置陷阱、浮点数值误差、Batch 动态调度、MoE 路由竞争以及底层算子差异。
工程实践中,更现实的目标不是追求数学意义上的“位级完全一致(Bit-wise Deterministic)”,而是通过参数控制、架构设计与缓存机制,把模型行为收敛在业务可接受的稳定性范围内。

理想化推理链路:
固定权重 + 固定输入 + Greedy 解码(每步选 argmax) + 完全确定的数值计算 ⇒ 输出必然完全一致。

  • temperature (温度):
    • 越高 → 分布越“平”,强随机采样;
    • 越低 → 分布越“尖”,趋向于 Greedy;
    • 理论上 temperature=0 对应“总是选最大值(Argmax)”,但工程上常被框架处理为“极小噪声采样”或依赖特定的实现逻辑。
  • seed (随机种子):
    • 仅控制“伪随机数生成器(RNG)”的初始化序列;
    • 只对依赖随机采样的步骤生效;
    • 对并行计算中的归约顺序(浮点误差)、Batch 调度策略等物理层面的随机性完全无效。

下文将按照 “排查优先级” 对这些干扰因素进行分层解析,并给出相应的规避策略。

推理过程与参数参与情况

工程中概率排行

请求配置层

我不是像 AI 一样让你检查 temperature=0 , 是要理解配置层不只是请求参数, 这是高频误区:

  • 仍然在用采样:top_p < 1top_k > 1,只是温度降到 0;
  • 请求 n > 1,服务端对多条采样结果再做内部选择;
  • 模型供应商 对 temperature=0 做了特殊处理或者忽略0,比如强制改成一个很小但非 0 的温度;
  • 部分供应商接口文档应该写的是 “mostly deterministic” 或 “best effort reproducibility”,并未承诺严格一致。

这些都意味着你以为关掉了随机性,实际上还在采样,而且 seed 只保证“这条采样序列可复现”,并不保证不采样。工程上遇到的多数“T=0 还在变”的案例,根因都在这一层。

参数建议
1
2
3
4
5
6
7
8
9
10
11
{
"model": "xxx",
"temperature": 0.0, // 或一个非常小的值,例如 0.1
"top_p": 1.0, // 不再做 nucleus 截断
"n": 1, // 不要生成多候选再让服务端挑
"seed": 42, // 固定为某个整数,在同一测试中保持不变
"presence_penalty": 0.0, // 禁用额外的随机去重行为
"frequency_penalty": 0.0,
"max_tokens": 256, // 评估/回归测试时也建议固定
"stop": null // 如无特殊需要,不要动态变化
}

请求调度层

Batch 批次请求打包产生差异

在云端 API 上,请求通常如下情况:

  • 被和其他用户的请求一起打包进不同大小的 batch;
  • 由底层推理引擎根据 batch 维度选择不同的并行 kernel 或归约策略。

这会导致:

  • 并行计算的归约顺序不确定导致浮点加法结果差异(不满足结合律)=> (a+b)+c!=a+(b+c)
  • attention / matmul / RMSNorm 的归约路径不同;
  • logits 在 1e-6 量级上产生差异;
  • 若两个 token 概率本来就非常接近,argmax 可能翻转,后续生成路径完全分叉。

《Non-Determinism of “Deterministic” LLM Settings》在理论“应当确定”的配置下反复测试,发现输出字符串的一致率明显小于100%,下游任务准确率在不同 run 间可以差十几个百分点。

对云 API 来说,这是最常见且几乎不可控的非确定性来源。用户也不可能决定好每个批次元素的归约顺序.

模型算子层

浮点并行本身就是非确定性的

即便你在本地单机推理,只要使用 GPU / 并行 kernel,也会碰到:

  • 并行归约(atomicAdd 等)导致累加顺序未定义;
  • cudnn/cublas/自定义 kernel 采用了不同实现路径;
  • 多线程抢占导致不同 run 间执行顺序略有差异。

数值差异微小,但 softmax + argmax 会放大这些差异;自回归生成会进一步放大第一步 argmax 的差异。

PyTorch 论坛中多次讨论:即使 model.eval(),也需要额外开启 deterministic 模式,
否则多次 inference 仍然无法 bit-wise 一致。

批次与浮点计算可以合在一起看,下图是一个简单的归因链路示意:

浮点非结合性效应

模型解码层

不同框架/服务对于 “temperature=0” 的实现并不统一:

  • 有的分支直接走 greedy,不采样;
  • 有的把 0 改成一个很小的正数,仍然进行采样;
  • 有的在低温下仍然允许 nucleus / top-k 筛选后采样。

再叠加 tie-breaking 策略:

  • 概率相等或近似相等时,是按 token id 排序选第一个
  • 还是仍然用 RNG 做一次随机决策

这些实现级细节,很容易在“理论上相同配置”的两次调用之间,积累成肉眼可见的输出差异。

实际代码里,几个主流高 star 项目对 temperature=0 的处理就已经完全不一样:Transformers 直接视为非法值, vLLM 把它解释成“强制 greedy 并重写 top_p/top_k”, llama.cpp 则在不同版本中先后把非正温度当作 greedy 的捷径、后来又要求配合 top‑k 才能得到真正的 greedy 行为。
这本身就说明:“temperature=0”的语义强依赖具体框架实现,目前并没有统一标准。

模型架构层

对采用 Mixture-of-Experts(MoE)的模型:

  • 每个 token 会先经过 gating 网络决定路由到哪些专家子网络;
  • 为了负载均衡,路由逻辑中可能包含截断、近似甚至随机裁剪;
  • 当不同 batch 下竞争同一专家时,调度顺序变化会改变路由结果。

链路复现性

模型/系统版本漂移, 部署的集群非一致, 请求的链路不一定落到系统、驱动、算子全一致的软硬件上。

在云端服务里,以下情况都很常见:

  • 模型权重热更新、系统 prompt 调整;
  • 不同 region / 集群挂载了略有差异的模型快照;
  • 路由策略在多个版本间做灰度。

同一个 model name 在不同时刻/不同 region 调用,底层实际上可能已经不是同一个模型实例。
这更常见于“隔一段时间”再次调用发现结果不同,而非“连续两次立刻不同”。

输入一致性

常见人祸因素:

  • prompt 拼装引入 time、userid、skill 等隐变量;
  • system prompt / few-shot demo 在不同调用间细微变化;
  • 不可见字符(BOM、零宽空格)或换行差异。

日志里看上去完全一样,但序列化出来并不一样。这一类问题本身不深,只说明排查非确定性前先校验输入字节级是否一致。

llm-Indeterminacy-why

如何尽量确定性

下面区分两种场景:云 API 调用 / 自建推理。

防御策略

黑盒对抗赛

  1. 配置层:参数去随机化
    • 配置:temperature 非 0 但极低(如 0.01–0.1),避免 0 被特殊处理;
    • 采样:锁定 top_p = 1、不使用 top_k、n = 1;
    • 文档:查阅各家关于 deterministic 字段的说明,按官方建议传参。
  2. 输入层:严格归一化 (针对“输入一致性”)
    • 确保在计算签名或调用前,对 Prompt 进行 字节级清洗对比, 严查时间戳等变量注入;
    • 剔除零宽空格、统一换行符 (\n vs \r\n)、并在 JSON 序列化时保证 key 顺序固定。
  3. 业务层:利用缓存构造“伪确定性” (针对“链路复现性”)
    • 定义一个请求签名: (model_version, temperature, top_p, system prompt, user prompt, seed);
    • 命中即返回:确保“相同参数 + 输入 ⇒ 永远返回同一条缓存结果”,在业务侧屏蔽底层的微小波动。
  4. 兜底策略:把 LLM 视为噪声组件
    • 结构化输出:LLM 生成候选 → Schema/Parser 严格校验 → 失败则重试;
    • 多数票机制:对关键分类任务发起 3 次调用取共识(Majority Vote);
    • 预期管理:在业务逻辑里显式兼容小范围的不一致。

控制策略

白盒自控主场赛

  1. 解码层:强制 Greedy
    • 在 vLLM / Transformers 中显式设置 do_sample=False;
    • 彻底禁用 top_p / top_k,防止框架内部的“低温采样”行为;
    • 若用 Beam Search,禁用所有涉及随机扰动的参数。
  2. 配置层:全链路种子固定
    • 代码级固定:torch.manual_seed、torch.cuda.manual_seed_all、numpy.random.seed;
    • 这只能解决“采样层”的随机性,无法解决“算子层”的并发噪声。
  3. 算子层:开启确定性模式 (牺牲性能)
    • 开启 PyTorch 确定性算法:torch.use_deterministic_algorithms(True);
    • 禁用优选 Benchmark:torch.backends.cudnn.benchmark = False;
    • 配置环境:设置 CUBLAS_WORKSPACE_CONFIG 使用确定性算法实现。
    • 代价:吞吐量显著下降,且部分高性能算子(FlashAttention 等)可能无法在确定性模式下运行。
  4. 调度层:牺牲 Batching (针对“批次差异”)
    • 独占推理:不跨请求拼 Batch,每个请求单独执行;
    • Batch-invariant 库:仅使用明确承诺了“批量无关性”的推理库版本。
    • 代价:GPU 利用率暴跌,成本激增。通常仅适用于离线审计或基准评测。

自建确定性策略带来的延迟倍率对比

数值精度与管线设计在重现性、性能与显存代价上的折中示意图

llm-Indeterminacy-do

总结

从研究与工程实践看:

  • 即使在 配置层 (temperature=0) 和 配置层 (seed) 拉满的情况下,算子层 和 调度层 的噪声依然存在;
  • 在云端 API 上追求 Bit-wise Deterministic 是不切实际的;
  • 在自建推理中,获得严格确定性的代价是大幅牺牲 吞吐与算力成本。

更合理的心智模型是:

LLM = 强大但带噪声的推理器
——在设计系统时,默认它“不完全可复现”,通过参数、缓存和上层逻辑来吸收这种噪声。

适合追求强一致性的环节(计费、风控、合规决策),应优先考虑确定性模型或规则系统;
LLM 更适合作为“辅助决策 + 文本代理”,而不是唯一的“权威判官”。

这样设计出来的系统,更符合当下大模型技术的真实边界。


文章主要借助 Perplexity + NotebookLM 检索文献和生成配图,对其中一篇核心参考强烈推荐原文阅读 Defeating Nondeterminism in LLM Inference

科艺知识库 ARM64

作者 钟意
2025年12月2日 22:00

前言

因某些需求需要一款文档管理与索引工具, 遂相中了科艺知识库, 并且构建了一份科艺知识库 arm64 架构版的, 记录构建过程.

部署

部署先放前面, 防止未来有急切的看客.

  1. 克隆源码:

  2. Docker 编排:

    1
    2
    3
    4
    5
    6
    7
    8
    # 克隆代码
    git clone https://git.thatcdn.cn/open/kykms.git

    # 工作目录
    cd ./kykms/deploy

    # 启动编排: 镜像已经适配 amd64/arm64 双版本 根据平台自动拉取
    docker compose up -d

构建

若看客系统架构非 amd64/arm64, 可参考构建过程.

elasticsearch

  1. 在 kykms/deploy/ES 目录下 Dockerfile 文件可知, arm64 架构没有 elasticsearch:7.6.1 版本, 遂换成 7.8.1
  2. 相应的 ES 目录下 elasticsearch.tar 打包的 analysis-ik 这个插件也需要换成对应的版本, 这个网址有存档 analysis-ik 全版本 => analysis-ik-all
  3. 下载对应的 analysis-ik.zip 解压再压缩为 analysis-ik.tar 替换原来的, 注意压缩包展开就是全部文件, 不要封一层目录.
  4. 至此修改 Dockerfile 并且构建得到 kykms-es
    es-build
    1
    2
    3
    4
    5
    6
    # 更新对应镜像
    FROM elasticsearch:7.8.1
    # 利用 ADD 自动解压到目标目录
    ADD elasticsearch.tar /usr/share/elasticsearch/plugins/analysis-ik
    # 默认工作目录,以免影响 ES 启动
    WORKDIR /usr/share/elasticsearch

mysql

在 kykms/deploy/DB 目录下 Dockerfile, 检查 mysql:5.7 是否有对应架构版本. 直接构建得到 kykms-mysql

redis

随便用一个版本为6就行, 构建得到 kykms-redis

web

  1. 在 kykms/ant-design-vue-jeecg 目录下用node构建前端项目得到 dist
  2. 构建镜像得到 kykms-web

api

  1. 在 kykms/jeecg-boot 目录下用maven/java构建后端项目得到 jar, 用 java8 的基础镜像安装libreoffice库 (WORD转PDF的). 我的建议是随便一个amd设备运行 registry.cn-guangzhou.aliyuncs.com/kyxxjs/kykms:comm 取得对应的 jar 和 字体, 省去构建过程.
  2. 构建镜像得到 kykms-api
    api-build
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    FROM docker.io/eclipse-temurin:8u472-b08-jdk
    LABEL maintainer="Joney K."
    # 因为是 ARM64 架构,apt 源必须指向 ports.ubuntu.com
    # 显式替换为对应架构加速源
    RUN sed -i 's/archive.ubuntu.com/ports.ubuntu.com/g' /etc/apt/sources.list && \
    sed -i 's/security.ubuntu.com/ports.ubuntu.com/g' /etc/apt/sources.list && \
    apt-get update && \
    apt-get install -y --no-install-recommends libreoffice fontconfig && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
    COPY ./simsun.ttc /usr/share/fonts/simsun.ttc
    RUN fc-cache -fv
    WORKDIR /kykms
    COPY ./jeecg-boot-module-system-2.4.5.jar ./app.jar
    EXPOSE 8080
    CMD ["java", "-Dfile.encoding=utf-8", "-jar", "app.jar"]

编排

替换 kykms/deploy/docker-compose.yml 里面的 image, 都换成新构建的即可

胶东半岛观察

作者 钟意
2025年10月8日 21:00

一面法定潮汐表,丈量着胶东半岛的野性与文明。

威海

一座偎在胶东半岛角落的精致小城。裙边是绿白清秀的不成连绵的矮山,前摆是弧度不那么规整的有小屿突触的海岸线。这种不加雕琢的美,像某个记忆里的僻如坪山碧岭的海滨小镇,安静而耐人寻味。

未经雕琢的安静被网红流量打破。无人机如新的候鸟群盘旋,三脚架在旧的观景点扎根。火炬八街的午后,人潮比海浪更懂得重复的艺术。而角落熙攘的垃圾桶,只是无声地装着鲜花、雨衣、折断的高跟鞋——显然它们没有海鸥挑食。随着潮汐退去又涨来,可知潮汐才是这片海岸真正的主人。

而我,也是这潮汐里的一滴水,可惜遵循的是法定潮汐表,而非岸边张贴的。

烟台

养马岛的海,是一块被时光遗忘的果冻。它不求深邃,却以清澈自持,让人相信海也可以是天然的泳池。这种坦诚,与威海的含蓄保持着默契的距离,正如牟平与威海的距离。它们像半岛伸出的两只手:一只捧着未经雕琢的安静,另一只,则盛着这汪见底的透明。

至于长岛的潮汐,我尚未赴约。或许下次,等我能分清,哪一滴水声来自太平洋,哪一声又只是我手机里定时响起的行程提示音。

青岛

青岛字如其名长青之岛。CBD的夜色,是资本与潮汐共同浇筑的结晶。五十层高空俯视,霓虹如血管般在楼宇间奔流,勾勒出金融与欲望的拓扑图。远处吊塔的红色信号灯明灭,像为这片填海而生的新城打着节拍——一种被精密计算的、永不疲倦的潮汐。

而浮山森林公园则扮演着它的反面。那里的山海如未被驯服的旧梦,松涛与海浪合谋,试图淹没来自CBD的电子脉冲。山路蜿蜒如静默的抵抗,提醒着人们:在成为国际湾区之前,青岛首先是一座岛。

真正的“人间”则散落在海边任何一寸可供落座的土地。无需帐篷,不论晨昏,支一把露营椅,男女老少便能面朝大海,为自己辟出一席之地。这并非精致的野趣,而是一种更朴素的与故里海浪的交流。

真正的“雕琢”藏在街角。道路护栏外侧悄然延展出休憩的桌椅,桌面之下,无线充电线圈正发出温柔的磁力。这已超越了便民服务,更像一场城市与过客达成的微妙契约:你为我停留,我予你能量。正如栈桥旁的太阳能座椅,白昼吸吮阳光,夜晚则为听海的过客释放些许光明——赛博朋克的光合作用。

在这座城,自然与科技并非对峙,而是达成了某种共谋。山海是底色,代码是笔触,金融是驱力,共同书写着一份既野性又文明的“法定潮汐表”。而每一位过客、每一个集装箱,既是观潮者,也是被计量的水滴。

结语

没参考攻略,一次极其简单的胶东半岛旅行观察。归程时蓝调被朝阳划破,麦田被高铁划破,两股潮汐被工作划破。明明远离了海边,那若隐若现的熙熙攘攘的潮汐却跟了一路,回到天津,更甚。原来都是回到自己的岛屿,在各自的法定表格上,一次次签到与签退,迎接岛屿的下一次潮汐。

附录: 旅拍

威海日落湾没有日落
眺望威海国际浴场
街拍第二国际浴场
烟台养马岛一角海景
青岛黄岛区俯拍夜景
唐鸟湾赶海敲敲敲
浮山森林公园远眺
早六的归程

海光 K100 DCU VLLM 推理环境构建

作者 钟意
2025年8月30日 10:00

系统环境

  • 系统: Kylin OS
  • 芯片: 128H, Hygon C86 7390 2S * 64
  • 显存: 128G, Hygon K100 DCU 64G * 2
  • 内存: 500G

基础驱动

PS: 详情参考 DTK环境安装与部署

  • DTK: 最新DTK列表
    1. 解压: tar -xzvf DTK*.tar.gz
    2. 载入环境: cd DTK* && source env.sh
    3. 测试: hipcc --version
  • 驱动: 最新驱动列表
    • 执行: ./rock*
    • 测试: hy-smi

部署资源

模型文件

可以从任意平台下载 vllm 所需要的离线模型文件,以下举例 HF、魔搭下载。

强烈建议像示例一样小模型测试,启动快看报错也快。

  • HuggingFace

PS: 也可以选择Git命令下载: git clone https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B

HF下载模型
1
2
3
4
5
6
7
8
9
10
11
# 基于PY环境安装HF脚手架
pip install huggingface-hub

# 镜像加速
$HF_ENDPOINT = "https://hf-mirror.com"

# 下载完整模型
huggingface-cli download --repo-type model \
deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
--local-dir ./DeepSeek-R1-Distill-Qwen-1.5B \
--local-dir-use-symlinks False
  • ModelScope(国内)

PS: 也可以选择Git命令下载: git clone https://www.modelscope.cn/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B.git

魔搭下载模型
1
2
pip install modelscope
modelscope download --model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B --local_dir ./DeepSeek-R1-Distill-Qwen-1.5B

环境镜像

与其它卡有所不同,因缺少 CDNA/GCN 架构的优化内核、未针对 Hygon 芯片做算子优化等原因,国产加速卡需要使用定制的镜像。

镜像按照自己的DCU驱动版本选择: 光源定制镜像

离线内网环境请先准备好镜像包导入。

部署服务

参数详情:

  • HIP_VISIBLE_DEVICES: 使用的显卡槽
  • HSA_OVERRIDE_GFX_VERSION: 匹配K100架构
  • --tensor-parallel-size: 使用显卡数量
  • --gpu-memory-utilization: 显卡使用率

编排文件: 请自行修改以下内容

  • environment里显卡数、显卡槽
  • command里model路径
  • volumes里映射的模型路径实际路径
docker-compose.yml
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
version: "3.9"

services:
vllm:
image: image.sourcefind.cn:5000/dcu/admin/base/pytorch:2.4.1-ubuntu22.04-dtk25.04.1-py3.10
container_name: vllm-test
environment:
- HIP_VISIBLE_DEVICES=0,1
- HSA_OVERRIDE_GFX_VERSION=10.3.0
command: >
python3 -m vllm.entrypoints.openai.api_server
--model /workspace/models/DeepSeek-R1-Distill-Qwen-1.5B
--tensor-parallel-size 2
--gpu-memory-utilization 0.9
--served-model-name ds-r1-1.5b
--dtype float16
--trust-remote-code
--enforce-eager
--host 0.0.0.0
--port 8000
network_mode: host
ipc: host
devices:
- "/dev/kfd:/dev/kfd"
- "/dev/dri:/dev/dri"
volumes:
- /opt/hyhal:/opt/hyhal:ro
- /workspace/service/vllm/models:/workspace/models:ro
cap_add:
- SYS_PTRACE
security_opt:
- seccomp=unconfined
group_add:
- video
- render
restart: unless-stopped

测试命令

一些打印测试的命令,成功部署可以忽略。

  • 测试服务是否正常运行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    curl -X POST "http://127.0.0.1:8000/v1/completions" \
    -H "Content-Type: application/json" \
    -d '{
    "model": "ds-r1-1.5b",
    "prompt": "Compute the Fourier transform of the constant function f(t) = 1. What should the correct answer be?",
    "max_tokens": 50,
    "temperature": 0.7
    }'

    # ====理论输出====
    # answer is 2πδ(ω)
  • 查看容器对显卡的识别

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    python3 - <<'PY'
    import torch
    print("PyTorch 版本:", torch.__version__)
    print("是否可见 HIP/GPU:", torch.cuda.is_available())
    print("GPU 数量:", torch.cuda.device_count())
    for i in range(torch.cuda.device_count()):
    print(f"设备 {i} 名称:", torch.cuda.get_device_name(i))
    PY

    # ====理论输出====
    # PyTorch 版本: 2.5.1
    # 是否可见 HIP/GPU: True
    # GPU 数量: 2
    # 设备 0 名称: K100
    # 设备 1 名称: K100

博客多平台负载均衡方案

作者 钟意
2025年6月21日 10:00

折中式 博客多平台负载均衡方案

前言

此前博客在 Vercel 托管,域名是腾讯云 CNAME 到 Vercel 的加速源,但是最近发现这个方案有如下问题:

  1. 神秘力量:有时网络只位于墙内或墙外,单向访问
  2. 额度耗尽:Vercel 的免费额度是 100G/月,这个月博客居然把额度耗尽
  3. 容灾能力:受单平台限制没有灾害转移能力,包括不限于对网络问题、额度问题的处理

基于以上问题,前天计划周末进行多平台负载均衡,增强博客的稳定性与白嫖性。虽然不能完美解决,但已经得到很大改善。

而且该方案可复用于无状态、一致性的项目,如:博客、文档、SPA、只读类接口、静态加速等等。毕竟额度不够,平台账号数量来凑。

问题

考虑到该方案的可能缺陷,如统计数据、SEO易混乱、部分平台行为不一致、首次 TLS 握手稍慢等等,未来将在此处记录出现的问题与解决方案。

2025-06-21

使用多平台负载均衡方案

方案

准备过程

  • 服务器:可以是微型服务器,只用来运行负载均衡程序。最好是香港服务器防止国内网络问题。
  • 域名:可以是任何域名,但需要解析到服务器的 IP 地址。
  • 负载均衡程序:可以是任何具备负载均衡程序,如 Nginx、OpenResty 等。
  • 多平台账号:Vercel、Netlify、Cloudflare 等。

设计思路

  1. 部署博客在Vercel等多平台,不需要配置DOMAIN,用默认的域名即可。
    • Hexo等框架通常生成一个静态的 public 文件夹,上传到 Git 仓库中在 Vercel、GitHub Pages、Cloudflare Pages、Netlify 等引用仓库部署。(后续推送会自动触发更新)
    • 需要的话服务器本身也可以托管一份
  2. 域名解析到服务器的IP地址
  3. 服务器安装负载均衡程序,自行搜索《Linux安装Nginx》之类的教程。
  4. 签发证书,新建站点,并配置规则。(配置完无需维护,除非部署平台有增减)

详细教程

视频教程

因为对于服务器新手配置可能过于宽泛与繁琐,录制了视频作为参考。

效果

网站测速

博客国内访问效果
博客国内访问效果
博客海外访问效果
博客海外访问效果

必应索引

测试了必应站点地图索引与网站扫描与往常一致。

局限性与解决思路

需要自建服务器

维护成本高于单纯用平台白嫖部署,但可以选择轻量 VPS(1H/1G 足矣)。

不具备主动健康检查

负载均衡程序本身不具备主动健康检查功能,无法动态剔除故障节点,只能故障转移。可通过 max_fails + 定时 reload 替代;未来可引入 Lua 脚本来实现。

SEO问题

多副本可能被搜索引擎识别为重复,可通过添加 canonical标签、站点地图等指向源站。(Stellar已实现)

部分平台行为不一致

如平台对404错误处理不一致,可添加自定义 404 页面。(Stellar已实现)

浅谈RAG

作者 钟意
2025年6月15日 10:00

RAG是权衡LLM的发散性与其准确性而诞生的产物

为何存在

RAG(Retrieval-Augmented Generation,检索增强生成)

诞生: 解决基础LLM的三个核心短板:

  1. 知识固化: 训练数据固定,无法动态更新知识,导致的知识时效性问题。
  2. 知识不足: 对冷门、专业领域、机密等特性的知识掌握有限。
  3. 事实幻觉: 生成看似合理但完全虚构的内容,比之更头疼的是混杂性幻觉。

价值: 低成本控制基础LLM:

  1. 数据可控: 将私有数据纳入检索库,避免敏感数据泄露给第三方基础模型。
  2. 引用追溯: 生成的答案附带检索到的参考文档,方便验证可信度与追溯来源。
  3. 成本效益: 相比微调大模型,RAG成本降低80%。
  4. 秒级更新: 允许秒级更新知识(股票、价格),而LLM微调需小时级耗时。

设计思想

RAG架构更像一位“学者”,在模仿人类认知双系统(快思考/慢思考)。先查阅文献,再写论文,而非仅凭记忆吃老本。

RAG的本质是将信息检索与文本生成结合,通过动态注入外部知识来增强LLM的能力。其核心逻辑是:

  • 检索阶段:从海量私有数据中精准筛选与问题相关的片段。
  • 生成阶段:LLM基于检索结果生成答案。

技术实现

RAG技术架构图

RAG技术架构图
RAG技术架构图
  1. 准备阶段
    1. 数据准备: 将私有准备的各类型数据利用分块技术进行切分。
    2. 数据向量化: 用嵌入模型将分块向量化。
    3. 数据落盘: 向量存入向量数据库,建立高效检索索引。
  2. 检索阶段
    1. 用户输入问题 → 转换为Embedding → 在向量库中搜索Top-K相似片段。
    2. 结合多模块检索、多跳检索、重排序、BM25等技术,提升召回的准确率。
  3. 生成阶段
    1. 将检索到的文档片段作为上下文,与用户问题一起输入LLM。
    2. 调优Prompt限制LLM发散性提高准确性。

企业落地

未来思考

既然开头说了是权衡的产物,那么发散性与准确性的平衡被打破时,RAG必将面临一个退位局面。

当LLM或者说另一种新的M突破知识固化与幻觉瓶颈时,RAG的“检索增强”功能可能逐渐隐入幕后,很多维护的LLM增强型RAG可能失去其存在的意义。

当然我没看空RAG,秒级更新与数据可控是无法替代。

在我看来未来王者退位,但荣光依旧。RAG不再以“独立技术”存在,但其设计思想会融入LLM架构,形成更智能的自我检索机制,成为LLM的“标准”之一。RAG不是过渡技术,而是人机协作的范式,RAG永远是LLM的移动硬盘。

Web技术构建桌面应用方案

作者 钟意
2025年6月8日 10:00

桌面应用方案

从Electron、Tauri、Flutter、pkg四个方案比较,打包复杂度中小web项目为例(vue构建结果10MB左右)

下表列出 Electron、Tauri、Flutter、pkg 四种方案在关键变量下的特性对比:

抉择变量ElectronTauriFlutterpkg
支持平台Windows/macOS/Linux (跨平台)Windows/macOS/Linux (跨平台)Windows/macOS/Linux(跨平台)Windows/macOS/Linux(依赖Node)
启动速度较慢(典型示例约4秒)较快(示例约2秒)一般(取决于硬件,Dart AOT编译)较快(纯Node环境,无浏览器启动开销)
内存占用较高(空闲时约100MB+)较低(空闲时约80MB)低(简单应用约25MB)较低(无UI时几十MB,不含浏览器进程)
CPU负载较高(多进程架构、Chromium开销)较低(Rust后端+系统WebView)低(编译为原生码、使用GPU加速)低(单进程Node,轻量运行)
打包体积较大(包含Chromium+Node,例如示例约244MB)很小(示例约8.6MB)中等(包含Flutter引擎,通常几十MB)中等(包含Node运行时,几十MB)
内置运行时内置 Chromium 和 Node.js不内置Node.js,使用系统 WebView 引擎内置 Dart VM(编译为本地二进制)内置 Node.js 运行时
运行环境依赖无需额外环境(Chromium已打包)需要目标系统提供对应 WebView(Win: WebView2;Linux/Mac: WebKit)需要目标系统对应的图形库和编译环境无需预装Node.js(运行时已包含在可执行文件中)
构建资源需求中等(需要安装Node依赖,下载Electron二进制)较高(需要安装Rust工具链,首次编译耗时较长)较高(需安装Flutter SDK及桌面支持工具)较低(仅需Node环境和pkg工具)
前端兼容性完全支持任意Web前端(Vue、React等)完全支持任意Web前端(Vue、React等)不使用HTML/JS,仅支持Flutter/Dart组件无原生前端,仅打包Node后台逻辑,不自带GUI
原生功能集成丰富的Electron API(窗口、托盘、通知等)+Node插件支持提供Rust后端API和插件(窗口、文件系统、托盘等,需显式暴露)通过插件或平台通道访问原生(文件系统、窗口管理、托盘可用第三方库)受限于Node能力,可调用系统命令或Node模块,通常用于CLI或后台逻辑
安全性中等(默认开启Node集成会增大风险;需严格启用Context Isolation等安全策略)高(默认安全模型严格,需要显式暴露API;Rust内存安全)良好(编译为原生,可执行文件难以反编译,但需自行管理应用权限)中等(打包后源代码不可见,有一定保护;无内置更新机制)
适用场景适合快速开发的跨平台富GUI应用,如桌面客户端工具、大型桌面应用适合对包体积和性能敏感的桌面应用,如小型工具、系统实用程序、高安全性需求的应用适合需要高性能UI和动画交互的应用,如游戏、多媒体应用或跨移动+桌面项目适合命令行工具或后台常驻程序,如自动化脚本、本地服务器等(不依赖图形界面)

注:
上表中的性能数据和包体积等来自 公开基准测试

简要分析

Electron

Electron 基于 Chromium 及 Node.js 运行时,支持 Windows、macOS、Linux 三大平台。它将 Web 应用封装为桌面应用,对于习惯 Web 开发的程序员来说,上手简单,功能强大。
性能方面:启动时间通常在几秒左右,内存与 CPU 消耗较高——Windows 下测试显示空闲状态约消耗 120 MB 内存。
包体积方面:整合 Chromium 和 Node,使得最终体积通常为几百 MB。
依赖方式:打包时已将所有运行时一并内置,终端用户无需额外配置。
前端兼容性:可任意使用 Vue、React 等现代框架。
原生接口:提供如窗口控制、系统托盘、文件访问、通知等丰富 API,并可直接使用 Node 模块。
安全性:默认允许主进程完全访问 Node,会带来潜在风险——推荐启用 contextIsolation、预加载脚本等安全策略。
更新机制:常见方案为 electron-updater 与 GitHub Releases 的结合,实现自动更新。

总结:生态成熟、开发快速,适合需要大量 Web 交互、复杂界面的大型应用,但在包体体积和运行效率上存在较大代价。

Tauri

Tauri 后端采用 Rust,界面部分使用操作系统自带的 WebView(例如 Win 的 WebView2,Linux/macOS 的 WebKit),实现了小巧和高效的目标。
性能方面:启动速度快,实测约 2 秒;Windows 空闲内存约 80 MB,多窗口下整体占用约 170 MB。
包体积方面:经测试仅约 8.6 MiB。
依赖方式:最终需要系统中预装相应 WebView 运行时。
开发成本:需要安装 Rust 工具链,首次编译时间较长,但后续增量编译迅速。
兼容性:支持 Vue、React 等任意 Web 技术。
原生接口:提供可控的 Rust 插件体系,默认不开放危险 API,提高安全性。
安全性:默认启用 CSP 和权限许可机制,攻击面极小。
更新机制:可内置轻量自更新模块,结合 JSON、HTTP 等方案完成。

总结:安全、高效、体积极小,适合轻量型或系统级工具,但对 Rust 生态掌握有所要求,社区相对年轻。

Flutter

Flutter 使用 Dart 编写,通过自带的 Skia 渲染引擎生成原生界面。桌面支持 Windows、macOS、Linux。
性能方面:编译为本地执行码,UI 流畅,GPU 加速友好。Linux 测试显示,轻量应用占用约 25 MB 内存、50 MB 磁盘,比 Electron 更轻量。
包体积方面:需打包 Flutter 引擎,“Hello World” 即超过 50 MB。
依赖方式:需要目标平台对应的 Flutter 运行库及工具链支持。
开发成本:需安装 Flutter SDK 和桌面构建插件,环境搭建稍重。
兼容性:不得使用 JS 框架,必须采用 Dart + Flutter。
原生接口:通过 plugin 或 platform channels,可调系统功能。
安全性:源码被编译,逆向相对困难;但需自行实现更新机制。

总结:适合 UI 复杂、动画丰富、还需移动+iOS支持的项目,不建议用于简单工具或仅 Web 前端项目。

pkg

pkg 将 Node.js 应用打包为可执行文件的工具,支持 Windows/macOS/Linux。
性能方面:性能接近原生 Node.js,启动迅速。
包体积方面:包含 Node 运行时,体积几十至一百多 MB,介于脚本与 Electron 之间。
依赖方式:无需用户预装 Node 环境。
开发成本:仅需 Node/npm 环境,配置简单。
兼容性:不支持 GUI,适用于 CLI 或后台业务。
功能支持:可调用任意 Node 模块与系统命令,适合自动化、脚本工具。
安全性:源码被打包,具基本保护,但无自动更新机制。

总结:最适合命令行工具、本地后台服务等无需前端的项目,不支持桌面 GUI。

基于项目类型推荐

  • Electron:适合需要快速开发、依赖丰富Web生态的大型跨平台桌面应用(如聊天客户端、IDE、管理工具等)。对于开发者熟悉Web栈的项目,Electron可实现复杂功能,但会带来较大包体和运行时开销。
  • Tauri:适合对应用体积、安全和性能敏感的场景,如系统实用工具、轻量级编辑器或企业级安全应用。Tauri 能制作极小的可执行文件,运行内存低,并内建安全策略。
  • Flutter:适合对UI/动画要求高的应用,如图形化界面、游戏、多媒体工具或需要同时覆盖移动和桌面的项目。Flutter 的原生性能强劲,但包体较大,且开发需使用 Dart 生态。
  • pkg:适合纯后端或命令行型工具(无需GUI),例如自动化脚本、CLI工具和后台服务。它可以打包Node应用为独立可执行文件,方便分发和部署。

除非真的只打包基于node的工具类,不轻易推荐 pkg. 非特定Dart编写, 即正常web项目推荐rust驱动的tauri打包构建. 亲身经验在占用和丝滑度来说尚佳!

构建参考

贴几个参考,然后再补构建参考教程嘞,又是拖更的一天

Spring WebSocket 错误

作者 钟意
2025年6月7日 16:10

1. JSR-356 容器握手失败

现象

  • 控制台没有任何 afterConnectionEstablished 日志
  • 客户端卡在握手阶段或报错超时

原因

  • Spring MVC 默认用 StandardWebSocketClient(),但未指定底层 WebSocketContainer
  • 嵌入式 Tomcat(JSR-356 实现)与默认容器不匹配,导致握手不发起

排查 & 解决

  1. 显式注入 JSR-356 容器:

    1
    2
    3
    4
    5
    @Bean
    fun webSocketClient(): WebSocketClient {
    val container = ContainerProvider.getWebSocketContainer()
    return StandardWebSocketClient(container)
    }
  2. 或干脆切换到 Reactor Netty 客户端:

    1
    2
    3
    4
    @Bean
    fun webSocketClient(): WebSocketClient {
    return ReactorNettyWebSocketClient()
    }
  3. 启动时应看到类似日志:

    1
    Downstream connection established for session: <id>
  4. 实在不行web等依赖都排除tomcat,自行在web依赖加上exclusions:

    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
    <!-- 1. Spring Boot Web,但排除默认 Tomcat -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
    <!-- 排除 Tomcat 嵌入式容器 -->
    <exclusion>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    </exclusion>
    </exclusions>
    </dependency>

    <!-- 2. 专门引入 Undertow 及其 JSR-356 支持 -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
    </dependency>
    <!-- Undertow 对 JSR-356 WebSocket 的实现 -->
    <dependency>
    <groupId>io.undertow</groupId>
    <artifactId>undertow-websockets-jsr</artifactId>
    <!-- 可根据 Spring Boot 版本选择合适版本,通常与 Spring Boot 兼容即可 -->
    </dependency>

    <!-- 3. Spring WebSocket 模块,用于控制握手与消息处理 -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <!-- 注意:Spring Boot Starter WebSocket 本身会带 Tomcat 的 WebSocket 支持,
    但由于我们已经排除了 Tomcat Starter,这里只会引入 spring-websocket 相关依赖,不会再带 tomcat-embed-websocket -->
    </dependency>

2. 路由没生效

现象

  • 配置了 WebSocketConfigurer,但 afterConnectionEstablished 并未触发
  • 日志中不见任何 /your/path 映射信息

原因

  • 构造函数注入 List<ISocketHandler> 导致循环依赖
  • Spring Boot 2.6+ 默认禁用循环引用,Bean 未注册
  • 或者忘记 @EnableWebSocket

排查 & 解决

  1. 确保配置类加上:

    1
    2
    3
    4
    5
    @Configuration
    @EnableWebSocket
    class WebSocketConfig(
    @Lazy private val handlers: List<ISocketHandler>
    ) : WebSocketConfigurer { … }
  2. 日志应包含:

    1
    Mapping “[/{registerPath}]” to WebSocketHandler

3. API 弃用与签名变化

3.1 Unresolved reference: handshake / execute

提示

1
2
Unresolved reference 'handshake'
Unresolved reference 'execute'

解读

  • StandardWebSocketClient 使用 doHandshake(...) 而非 execute
  • execute() 属于 WebFlux Reactor 客户端,与 MVC 客户端不通

修正

  • MVC 客户端:

    1
    2
    client.doHandshake(handler, uri)
    .addCallback({ sess -> … }, { ex -> … })
  • Reactor 客户端:

    1
    2
    3
    client.execute(URI.create(uri)) { wsSession ->

    }.subscribe()

3.2 doHandshake(...) 自 6.0 起弃用

提示

1
'doHandshake(WebSocketHandler, String, Object...)' 自版本 6.0 起已弃用并标记为移除

说明

  • Spring Framework 6 推荐注入 WebSocketContainer 或切换到 Reactor Netty
  • 暂可忽略警告,或升级为新版推荐 API

4. 依赖冲突 & 类加载

典型报错

1
2
ClassNotFoundException: javax.websocket.ContainerProvider
NoSuchMethodError: jakarta.websocket.ContainerProvider.getWebSocketContainer()

原因

  • Spring Boot 3.x 使用 jakarta.websocket,2.x 使用 javax.websocket
  • 嵌入式 Tomcat/WebSocket API 版本与项目引入冲突

解决

  • 统一依赖至一个版本,且与 Spring Boot 主版本匹配

  • 必要时在依赖中排除冲突项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-websocket</artifactId>
    <exclusions>
    <exclusion>
    <groupId>jakarta.websocket</groupId>
    <artifactId>jakarta.websocket-api</artifactId>
    </exclusion>
    </exclusions>
    </dependency>

5. 其他常见错误

错误类型典型现象 / 异常排查要点
循环依赖BeanCreationException: circular reference使用 @Lazy 或拆分配置
无效 JSONJsonParseException: Unexpected character (‘“’)前端必须用英文双引号;捕获异常并 friendly 返回
ConcurrentModificationExceptionjava.util.ConcurrentModificationException迭代时修改集合;先转成列表再遍历
NullPointerExceptionCannot invoke "JsonNode.asText()" ... get(...)判空或使用 ?.asText(default)

SpringBoot WebSocket 代理模式、客户端模式

作者 钟意
2025年6月7日 16:00

前言

  1. 本文实现 上下游ws的代理功能、客户端发布功能
  2. 开发语言:Spring Boot + Kotlin
  3. 实现方式很多种,这里给出接口代码是思路,可以改 @ServerEndpoint 托管实现

代理模式

用户连接为上游,被代理地址为下游。

  1. 劫持控制修改上下游消息内容
  2. 对上游进行鉴权

时序设计

IWebSocketProxier时序图
IWebSocketProxier时序图
时序代码
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
sequenceDiagram
participant UpClient as 上游客户端
participant Proxier as IWebSocketProxier
participant DownClient as 下游服务
participant Scheduler2 as 清理线程

Note over UpClient,Proxier: 1. 上游连接建立
UpClient->>Proxier: WebSocket 握手
activate Proxier
Proxier-->>Proxier: afterConnectionEstablished(session)
Proxier-->>Proxier: sessions[session.id] = WebSocketProxySession(...)
Proxier-->>Proxier: onUpstreamOpen(proxy)
deactivate Proxier

Note over UpClient,Proxier: 2. 上游首条消息(授权)
UpClient->>Proxier: TextMessage(首次消息)
activate Proxier
Proxier-->>Proxier: handleMessage(session, message)
Proxier-->>Proxier: onUpstreamFirstMessage(proxy, message)
alt 授权失败
Proxier-->>UpClient: sendMessage(授权失败通知)
Proxier-->>Proxier: closeSession(session.id)
else 授权成功
Proxier-->>Proxier: proxy.authorized = true
Proxier-->>Proxier: onAuthSuccess(proxy)
Proxier-->>Proxier: connectDownstream(session.id)
Proxier-->>Proxier: downstreamContexts[session.id].pending.offer(clone(message))
end
deactivate Proxier

Note over UpClient,Proxier: 3. 上游后续消息
UpClient->>Proxier: TextMessage(后续消息)
activate Proxier
Proxier-->>Proxier: handleMessage
alt !downConnected
Proxier-->>Proxier: pending.offer(clone(message))
else 已连接下游
Proxier-->>DownClient: sendToDownstream(transformUpstream(message))
end
deactivate Proxier

Note over DownClient,Proxier: 4. 下游连接建立完成
DownClient->>Proxier: 握手完成
activate Proxier
Proxier-->>Proxier: ctx.downConnected = true
Proxier-->>Proxier: flush pending → sendToDownstream(...)
deactivate Proxier

Note over DownClient,Proxier: 5. 下游消息回传
DownClient->>Proxier: TextMessage(下游响应)
activate Proxier
Proxier-->>UpClient: proxy.session.sendMessage(transformDownstream(msg))
deactivate Proxier

Note over Scheduler2,Proxier: 6. 会话超时自动清理
Scheduler2->>Proxier: cleanupExpired()
activate Proxier
Proxier-->>Proxier: closeSession(超时 session.id)
deactivate Proxier

Note over UpClient,Proxier: 7. 上游主动关闭
UpClient->>Proxier: closeConnection
activate Proxier
Proxier-->>Proxier: afterConnectionClosed(session, status)
Proxier-->>Proxier: closeSession(session.id)
deactivate Proxier

接口代码

IWebSocketProxier
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.web.socket.*
import org.springframework.web.socket.client.WebSocketClient
import org.springframework.web.socket.handler.AbstractWebSocketHandler
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

/**
* 包装上游会话及其状态,用于管理授权和心跳
*
* @param session WebSocket 上游会话
* @param authorized 是否已通过授权验证
* @param downConnected 下游连接是否已建立
* @param lastHeartbeat 最近心跳时间戳(毫秒)
*/
data class WebSocketProxySession(
val session: WebSocketSession,
var authorized: Boolean = false,
var downConnected: Boolean = false,
var lastHeartbeat: Long = System.currentTimeMillis()
)

/**
* 通用 WebSocket 代理抽象类
*
* 负责管理上游和下游的连接生命周期、消息转发以及超时清理
*
* 使用方式:
* 1. 实现核心抽象方法:
* - registerPath: 定义代理路由
* - onUpstreamFirstMessage: 处理上游首条消息并进行授权
* - downstreamUri: 获取下游 URI
* - transformUpstream: 上游→下游 转换逻辑
* - transformDownstream: 下游→上游 转换逻辑
* 2. 可选覆盖钩子:
* - onUpstreamOpen: 上游连接初始化
* - onAuthSuccess: 授权成功回调
* - onUpstreamFirstMessageIsNull: 授权失败处理
* - onSessionClosed: 会话关闭后处理
*
* @param objectMapper 用于 JSON 序列化/反序列化
* @param client WebSocket 客户端,用于建立下游连接
* @author ThatCoder
*/
abstract class IWebSocketProxier(
val objectMapper: ObjectMapper,
private val client: WebSocketClient
) : AbstractWebSocketHandler() {
/** 代理接入路径 */
abstract val registerPath: String

/** 会话超时时间,默认 10 分钟 */
open val sessionTimeoutMillis: Long = 10 * 60 * 1000

private val logger = LoggerFactory.getLogger(this::class.java)
private val sessions = ConcurrentHashMap<String, WebSocketProxySession>()
private val downstreamContexts = ConcurrentHashMap<String, DownstreamContext>()
private val scheduler = Executors.newSingleThreadScheduledExecutor(
NamedThreadFactory("proxy-session-timeout-")
)

init {
// 定期清理超时会话
scheduler.scheduleAtFixedRate(
{ cleanupExpired() },
sessionTimeoutMillis,
sessionTimeoutMillis,
TimeUnit.MILLISECONDS
)
}

override fun afterConnectionEstablished(session: WebSocketSession) {
logger.info("Upstream connected: ${session.id}")
sessions[session.id] = WebSocketProxySession(session)
onUpstreamOpen(sessions[session.id]!!)
}

override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
val proxy = sessions[session.id] ?: return
if (!proxy.authorized) {
val ok = onUpstreamFirstMessage(proxy, message)
if (!ok) {
onUpstreamFirstMessageIsNull(proxy)
closeSession(session.id)
return
}
proxy.authorized = true
onAuthSuccess(proxy)
connectDownstream(session.id)
downstreamContexts[session.id]?.pending?.offer(clone(message))
return
}
val ctx = downstreamContexts[session.id] ?: return
if (!ctx.downConnected.get()) {
ctx.pending.offer(clone(message))
} else {
ctx.sendToDownstream(transformUpstream(message))
}
}

override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
logger.info("Upstream closed: ${session.id}")
closeSession(session.id)
}

/**
* 向所有上游会话发送心跳,维持长连接
*/
fun sendHeartbeat() {
val ping = PingMessage()
sessions.values.forEach {
try {
it.session.sendMessage(ping)
} catch (_: Exception) {
// 忽略发送失败
}
}
}

// ---------- 可覆盖钩子 ----------

/** 上游连接建立后回调 */
protected open fun onUpstreamOpen(proxy: WebSocketProxySession) = Unit

/**
* 上游首条消息处理并授权
* @return true 表示通过,false 则触发授权失败
*/
protected abstract fun onUpstreamFirstMessage(
proxy: WebSocketProxySession,
message: WebSocketMessage<*>
): Boolean

/** 授权失败发送给上游的消息 */
protected open fun onUpstreamFirstMessageIsNull(proxy: WebSocketProxySession) {
val err = mapOf("finish" to true, "error" to "身份认证失败")
proxy.session.sendMessage(TextMessage(objectMapper.writeValueAsString(err)))
}

/** 授权成功后回调 */
protected open fun onAuthSuccess(proxy: WebSocketProxySession) = Unit

/** 根据上游会话获取下游 URI */
protected abstract fun downstreamUri(proxy: WebSocketProxySession): String

/** 上游→下游 消息转换 */
protected abstract fun transformUpstream(message: WebSocketMessage<*>): WebSocketMessage<*>

/** 下游→上游 消息转换 */
protected abstract fun transformDownstream(message: WebSocketMessage<*>): WebSocketMessage<*>

/** 会话关闭后回调 */
protected open fun onSessionClosed(proxy: WebSocketProxySession) = Unit

// ---------- 内部逻辑 ----------

/**
* 建立下游连接,并将后续消息路由到 DownstreamContext
*/
private fun connectDownstream(sessionId: String) {
val proxy = sessions[sessionId]!!
val ctx = DownstreamContext(proxy)
downstreamContexts[sessionId] = ctx
client.execute(object : AbstractWebSocketHandler() {
override fun afterConnectionEstablished(down: WebSocketSession) {
logger.info("Downstream connected for: $sessionId")
ctx.downConnected.set(true)
ctx.downstream = down
while (true) {
val msg = ctx.pending.poll() ?: break
ctx.sendToDownstream(transformUpstream(msg))
}
}

override fun handleMessage(down: WebSocketSession, msg: WebSocketMessage<*>) {
proxy.session.sendMessage(transformDownstream(msg))
}

override fun afterConnectionClosed(down: WebSocketSession, status: CloseStatus) {
logger.warn("Downstream closed early: ${status.code}")
closeSession(sessionId)
}
}, downstreamUri(proxy))
}

/** 关闭并清理指定会话 */
private fun closeSession(sessionId: String) {
sessions.remove(sessionId)?.also { onSessionClosed(it) }
downstreamContexts.remove(sessionId)?.closeAll()
}

/** 清理超时会话 */
private fun cleanupExpired() {
val now = System.currentTimeMillis()
sessions.entries
.filter { now - it.value.lastHeartbeat > sessionTimeoutMillis }
.forEach { closeSession(it.key) }
}

/** 克隆消息以避免并发问题 */
private fun clone(msg: WebSocketMessage<*>): WebSocketMessage<*> = when (msg) {
is TextMessage -> TextMessage(msg.payload)
is BinaryMessage -> BinaryMessage(msg.payload.asReadOnlyBuffer())
else -> msg
}

/**
* 管理下游消息发送及队列
*/
private class DownstreamContext(proxy: WebSocketProxySession) {
@Volatile var downstream: WebSocketSession? = null
val downConnected = AtomicBoolean(false)
val pending = ConcurrentLinkedQueue<WebSocketMessage<*>>()
private val executor: ExecutorService = ThreadPoolExecutor(
4, 16, 60, TimeUnit.SECONDS,
LinkedBlockingQueue(1000),
NamedThreadFactory("proxy-send-${proxy.session.id}")
)

/** 将消息异步发送到下游 */
fun sendToDownstream(msg: WebSocketMessage<*>) {
executor.execute {
try {
downstream?.sendMessage(msg)
} catch (e: Exception) {
LoggerFactory.getLogger("DownstreamLogger").error("Send downstream failed", e)
}
}
}

/** 关闭下游并清理资源 */
fun closeAll() {
try {
downstream?.close()
} catch (_: Exception) {
}
executor.shutdownNow()
pending.clear()
}
}

/** 为线程池生成可读性线程名 */
private class NamedThreadFactory(prefix: String) : ThreadFactory {
private val cnt = AtomicInteger(1)
private val name = "${prefix}-${cnt.getAndIncrement()}"
override fun newThread(r: Runnable) = Thread(r, name)
}
}

实现示例

以代理 FunAsr 为例,统一上下游的消息类型,对上游进行身份权限认证

AsrProxier
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
import com.bidr.waterx.transpond.config.extension.fieldJust
import com.bidr.waterx.transpond.config.extension.fieldRemove
import com.bidr.waterx.transpond.config.extension.fieldRename
import com.bidr.waterx.transpond.config.extension.putMap
import com.bidr.waterx.transpond.config.extension.toObjectNode
import com.bidr.waterx.transpond.config.extension.toTextMessage
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketMessage
import org.springframework.web.socket.client.WebSocketClient

/** ASR 代理实现 */
@Component
class AsrProxier(
objectMapper: ObjectMapper,
webSocketClient: WebSocketClient,
private val akService: IAKService
) : IWebSocketProxier(objectMapper, webSocketClient) {
private val paramProxy = mapOf(
"id" to "wav_name",
"finish" to "is_speaking",
"answer" to "text"
)

private val logger = LoggerFactory.getLogger(this::class.java)

override val registerPath = "/ws/asr"

// 鉴权服务
override fun onUpstreamFirstMessage(proxy: WebSocketProxySession, message: WebSocketMessage<*>): Boolean {
val node = message.toObjectNode(objectMapper) ?: return false
val ak = node.get("ak")?.asText() ?: return false
return akService.check(ak)
}

override fun downstreamUri(proxy: WebSocketProxySession) = "ws://localhost:10095"

// 处理上游消息适配成FUNASR接收类型
override fun transformUpstream(message: WebSocketMessage<*>) = when (message) {
is TextMessage -> runCatching {
val forward = message.toObjectNode(objectMapper)?.fieldRename(paramProxy) ?: return message
forward.get("is_speaking")?.let {
val finished = it.asBoolean(false)
if (!finished) forward.putMap(mapOf(
"language" to "zn",
"itn" to false,
"hotwords" to "{\"阿里巴巴\":20,\"hello world\":40}"
))
forward.put("is_speaking", !finished)
}
forward.get("mode")?.let {
if (listOf("mixed","online").contains(it.asText())) {
val arr = objectMapper.createArrayNode().add(5).add(10).add(5)
forward.set<ArrayNode>("chunk_size", arr)
forward.put("chunk_interval", 10)
if (it.asText() == "mixed") forward.put("mode", "2pass")
}
}
forward.fieldRemove(listOf("ak"))
logger.info("transformUpstream: $forward")
forward.toTextMessage(objectMapper)
}.getOrDefault(message)
else -> message
}

// 处理下游消息适配成客户接收类型
override fun transformDownstream(message: WebSocketMessage<*>) = when (message) {
is TextMessage -> {
val forward = message.toObjectNode(objectMapper)
?.fieldRename(paramProxy.toMutableMap().plus("finish" to "is_final"), true)
?: return message
forward.putMap(mapOf(
"mode" to when (forward.get("mode")?.asText() ?: "2pass-offline") {
"2pass-online" -> "online"
"2pass-offline" -> "offline"
else -> forward.get("mode").asText()
},
"timestamp" to System.currentTimeMillis()
))
forward.fieldJust(paramProxy.keys.plus("mode").toList())
logger.info("transformDownstream: $forward")
forward.toTextMessage(objectMapper)
}
else -> message
}

override fun onUpstreamFirstMessageIsNull(proxy: WebSocketProxySession) {
super.onUpstreamFirstMessageIsNull(proxy)
proxy.session.close(CloseStatus.POLICY_VIOLATION)
}
}

客户端模式

客户端模式是自己为发布器,用户为上游,自己作为下游。

  1. 用户认证
  2. 会话对象维护
  3. 心跳维护
  4. 消息广播
  5. 消息过滤广播
  6. 单例模式

时序设计

IWebSocketPublisher时序图
IWebSocketPublisher时序图
时序代码
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
sequenceDiagram
participant Client as 客户端
participant Publisher as IWebSocketPublisher
participant Scheduler as 定时清理线程

Note over Client,Publisher: 1. 连接建立与初始化
Client->>Publisher: WebSocket 握手并建立连接
activate Publisher
Publisher-->>Publisher: afterConnectionEstablished(session)
Publisher-->>Publisher: onUpstreamOpen(session)
deactivate Publisher

Note over Client,Publisher: 2. 首次消息(身份验证)
Client->>Publisher: TextMessage(首次消息)
activate Publisher
Publisher-->>Publisher: handleMessage(session, message)
Publisher-->>Publisher: onUpstreamFirstMessage(session, message)
alt 验证失败
Publisher-->>Client: onUpstreamFirstMessageIsNull → 发送错误提示
Publisher-->>Client: session.close(POLICY_VIOLATION)
else 验证成功
Publisher-->>Publisher: sessions[session.id] = WebSocketSenderSession(...)
Publisher-->>Publisher: onAuthSuccess(...)
end
deactivate Publisher

Note over Client,Publisher: 3. 后续业务消息处理
Client->>Publisher: TextMessage(业务消息) 或 PingMessage(心跳)
activate Publisher
Publisher-->>Publisher: handleMessage
alt 心跳
Publisher-->>Publisher: 更新 lastHeartbeat
else 业务消息
Publisher-->>Publisher: onUpstreamMessage(...)
end
deactivate Publisher

Note over Scheduler,Publisher: 4. 会话超时清理
Scheduler->>Publisher: cleanupExpired()
activate Publisher
Publisher-->>Publisher: 关闭过期 session → onSessionClosed
deactivate Publisher

Note over Publisher,Client: 5. 发布/广播/心跳
Publisher->>Client: publishAll/publishByFilter/publishSender
Publisher-->>Publisher: transformPublish(...)
Publisher-->>Client: sendMessage(转换后消息)
Publisher->>Client: sendHeartbeat() → PingMessage()

接口代码

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
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.web.socket.*
import org.springframework.web.socket.handler.AbstractWebSocketHandler
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger

/**
* 会话、用户信息的包装类
*/
data class WebSocketSenderSession<T>(
val session: WebSocketSession,
val user: T,
/** 心跳超时标志 */
val lastHeartbeat: Long = System.currentTimeMillis()
)

/**
* WebSocket 发布者抽象接口,用于构建支持用户认证、心跳维护、消息广播的通用 WebSocket 服务。
*
* ### 使用方式
*
* #### 必须实现
* > 继承本类并实现以下核心抽象方法
* - [registerPath]:注册的路径,WebSocket 接入入口
* - [onUpstreamFirstMessage]:处理上游客户端首次连接时的消息,一般用于身份验证,返回的用户信息将用于标识会话;若返回 null,连接将被关闭
* - [onUpstreamMessage]:处理客户端后续发送的消息
*
* #### 可选重写
* - [onUpstreamOpen]:连接建立但未发送任何消息时的初始化回调
* - [onSessionClosed]:连接关闭后的回调处理
* - [onUpstreamFirstMessageIsNull]:首次消息认证失败时的回调,默认发送错误信息
* - [onAuthSuccess]:首次消息认证通过后的回调
* - [transformPublish]:发送消息前进行的消息变换
*
* ### 会话管理
* - 会话信息以 [WebSocketSenderSession] 包装,包含 `session`、用户信息及心跳时间
* - 默认 10 分钟未活跃会话将被关闭,可通过 [sessionTimeoutMillis] 调整
*
* ### 发布功能
* - [publishAll]:向所有连接发布消息
* - [publishByFilter]:根据过滤条件发布消息
* - [publishSender]:向单个连接发送消息
* - [sendHeartbeat]:向所有连接发送 Ping 消息,维持长连接
*
* @param objectMapper Jackson 用于 JSON 序列化/反序列化
* @param T 用户类型,需由 [onUpstreamFirstMessage] 提供
* @author ThatCoder
*/
abstract class IWebSocketPublisher<T>(
private val objectMapper: ObjectMapper
) : AbstractWebSocketHandler() {

abstract val registerPath: String

/** 会话超时毫秒数 默认十分钟 */
val sessionTimeoutMillis: Long = 10*60*1000

private val logger = LoggerFactory.getLogger(this::class.java)

/** 所有会话管理器 */
val sessions = ConcurrentHashMap<String, WebSocketSenderSession<T>>()
private val scheduler = Executors.newSingleThreadScheduledExecutor(NamedThreadFactory("session-timeout-"))

init {
// 定期清理超时会话
scheduler.scheduleAtFixedRate({ cleanupExpired() }, sessionTimeoutMillis, sessionTimeoutMillis, TimeUnit.MILLISECONDS)
}

override fun afterConnectionEstablished(session: WebSocketSession) {
logger.info("Client connected: ${session.id}")
onUpstreamOpen(session)
}

override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
// 首次消息处理授权与注册
if (!sessions.containsKey(session.id)) {
val user = onUpstreamFirstMessage(session, message)
if (user == null) {
onUpstreamFirstMessageIsNull(session)
session.close(CloseStatus.POLICY_VIOLATION)
return
}
sessions[session.id] = WebSocketSenderSession(session, user)
onAuthSuccess(sessions[session.id]!!)
logger.info("Session registered: ${session.id} -> $user")
return
}
// 心跳更新或具体消息处理
onUpstreamMessage(sessions[session.id]!!, message)
}

override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
logger.info("Client closed: ${session.id} (${status.reason})")
sessions.remove(session.id)?.let { onSessionClosed(it) }
}

/**
* 全局发布消息
* @param message 消息
*/
fun publishAll(message: WebSocketMessage<*>) {
sessions.values.forEach { sender -> send(sender, message) }
}

/**
* 按过滤器发布
* @param filter 过滤器
* @param message 消息
*/
fun publishByFilter(filter: (WebSocketSenderSession<T>) -> Boolean, message: WebSocketMessage<*>) {
sessions.values.filter(filter).forEach { send(it, message) }
}

/**
* 发送消息给指定会话
* @param sender 会话
* @param message 消息
*/
fun publishSender(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>) {
send(sender, message)
}

/** 发送心跳 */
fun sendHeartbeat() {
val ping = PingMessage()
sessions.values.forEach {
try { it.session.sendMessage(ping) } catch (_: Exception) {}
}
}

// ========== 子类扩展点 ===========

/**
* 会话首次创建时调用
* @param session 会话
*/
protected open fun onUpstreamOpen(session: WebSocketSession) = Unit

/**
* 会话消息
* @param sender 会话对象
* @param message 消息
*/
protected abstract fun onUpstreamMessage(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>)

/**
* 会话关闭时调用
* @param sender 会话对象
*/
protected open fun onSessionClosed(sender: WebSocketSenderSession<T>) = Unit

/**
* 会话首条消息
*
* 通常在验证用户权限时调用
* @param session 会话
* @param message 消息
* @return 用户信息 如果返回null则触发 onUpstreamFirstMessageIsNull
* @see onUpstreamFirstMessageIsNull
*/
protected abstract fun onUpstreamFirstMessage(
session: WebSocketSession,
message: WebSocketMessage<*>
): T?

/**
* 会话首条消息处理为空时调用
* @param session 会话
*/
protected open fun onUpstreamFirstMessageIsNull(session: WebSocketSession) {
val err = mapOf("error" to "身份认证失败")
session.sendMessage(TextMessage(objectMapper.writeValueAsString(err)))
}

/**
* 认证成功后执行
* @param sender 会话
*/
protected open fun onAuthSuccess(sender: WebSocketSenderSession<T>) = Unit

/** 清理超时会话 */
private fun cleanupExpired() {
val now = System.currentTimeMillis()
sessions.values.filter { now - it.lastHeartbeat > sessionTimeoutMillis }
.forEach {
try { it.session.close(CloseStatus.SESSION_NOT_RELIABLE) } catch (_: Exception) {}
sessions.remove(it.session.id)
logger.info("Session timeout removed: ${it.session.id}")
}
}

private fun send(sender: WebSocketSenderSession<T>, message: WebSocketMessage<*>) {
if (!sender.session.isOpen) return
try {
sender.session.sendMessage(transformPublish(message, sender))
} catch (e: Exception) {
logger.error("Publish to ${sender.session.id} failed", e)
}
}

protected open fun transformPublish(
message: WebSocketMessage<*>,
sender: WebSocketSenderSession<T>
): WebSocketMessage<*> = message

private class NamedThreadFactory(prefix: String) : ThreadFactory {
private val cnt = AtomicInteger(1)
private val name = prefix + cnt.getAndIncrement()
override fun newThread(r: Runnable): Thread {
return Thread(r, name)
}
}
}

实现示例

以实现聊天室为例,这个例子有对单对群发送演示

兼容单例模式,只使用 publishSender 方法即可, 相当于一对一服务

  • 实现后可以多开几个网页测试 websocket测试网页
  • 链接本地 ws://localhost:8080/ws/chat后可以发送一个body鉴权进群 {"ak": "123456", "message": "我是卢本伟", "name": "卢本伟"}
  • 进群后续可以不发送 ak,已经有了sessionId对应的用户, 后面发送 {"message": "欢迎来到卢本伟广场"} 即可
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
import com.bidr.waterx.transpond.config.extension.toObjectNode
import com.bidr.waterx.transpond.config.extension.toTextMessage
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Component
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketMessage
import org.springframework.web.socket.WebSocketSession

data class ChatUser(val userid: String, val name: String)

/**
* 聊天室发布者
*/
@Component
class ChatPublisher(private val objectMapper: ObjectMapper) : IWebSocketPublisher<ChatUser>(objectMapper) {

override val registerPath = "/ws/chat"

override fun onUpstreamFirstMessage(session: WebSocketSession, message: WebSocketMessage<*>): ChatUser? {
val message = message.toObjectNode() ?: return null
val ak = message.get("ak")?.asText() ?: return null
val name = message.get("name")?.asText() ?: return null
if (ak != "123456") return null
// 创建用户
val user = ChatUser( session.id, name)
// 给该用户发送欢迎信息
session.sendMessage(TextMessage("Hi, $name. Please chat friendly!"))
// 群发用户入群提示
publishAll(TextMessage("$name've joined the chat room."))
return user
}

override fun onSessionClosed(sender: WebSocketSenderSession<ChatUser>) {
// 群发用户离开提示
publishAll(TextMessage("${sender.user.name} has left the chat room."))
}

override fun onUpstreamMessage(sender: WebSocketSenderSession<ChatUser>, message: WebSocketMessage<*>) {
// 转发用户消息至群聊
publishAll(objectMapper.createObjectNode().apply {
put("type", "chat")
putPOJO("user", sender.user)
putPOJO("message", message.toObjectNode())
}.toTextMessage())
}
}

路由注册

两个接口都有 registerPath 所以我们可以让 Spring 收集 IWebSocketPublisher、IWebSocketProxier 的实现类,自动注册里面的路由实现

WebSocketConfig
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
package cn.uwant.auto.config

import IWebSocketProxier
import IWebSocketPublisher
import jakarta.websocket.ContainerProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.client.WebSocketClient
import org.springframework.web.socket.client.standard.StandardWebSocketClient
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
import org.springframework.context.annotation.Lazy
import kotlin.collections.map

/**
* WebSocket配置
* @author ThatCoder
*/
@Configuration
@EnableWebSocket
class WebSocketConfig(
@Lazy private val proxies: List<IWebSocketProxier>,
@Lazy private val publishers: List<IWebSocketPublisher<*>>
) : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
proxies.map {
registry.addHandler(it, it.registerPath).setAllowedOrigins("*")
}
publishers.map {
registry.addHandler(it, it.registerPath).setAllowedOrigins("*")
}
}
@Bean
fun webSocketClient(): WebSocketClient {
val container = ContainerProvider.getWebSocketContainer()
return StandardWebSocketClient(container)
}
}

相关错误

见 BUG 专栏

Spring AOP 调用自身失效

作者 钟意
2025年3月23日 20:00

错误情景

  • 环境:
    • Spring: 2.7.18
  • 操作:
    1. 顶层方法分析所选数据源
    2. 切换数据源
    3. 调用对应查询
    4. AOP代理失效导致多数据源切换失败
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
/**
* 数据库元数据服务实现
*/
@Service
public class DataBaseMetaService implements IDataBaseMetaService {

/**
* 获取输入库表在数据库里的字段注释
*/
@Override
public List<DataBaseMateField> selectTableFieldsByScope(String dbName, String tableName) {
List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty();
switch (dbName) {
case "xxx":
dataBaseMetaFields = byMaster(tableName);
break;
case "xxxxxx":
dataBaseMetaFields = byMonitorHm(tableName);
if (CollectionUtils.isEmpty(dataBaseMetaFields))
dataBaseMetaFields = byMonitorWce(tableName);
break;
default:
break;
}
return dataBaseMetaFields.stream().peek(field ->
field.setFieldName(StringUtil.toCamelCase(field.getFieldName()))
).collect(Collectors.toList());
}

@DS("a")
public List<DataBaseMateField> byMaster(String tableName) {}

@DS("b-a")
public List<DataBaseMateField> byMonitorHm(String tableName) {}

@DS("b-b")
public List<DataBaseMateField> byMonitorWce(String tableName) {}
}

错误诱因

  1. Spring AOP 原理:@DS 依赖 AOP 代理,而 this.xxx() 直接调用自身方法,不会经过代理对象。

  2. JDK 代理与 CGLIB 代理的区别:默认情况下,@Transactional 和 @DS 这种 AOP 机制都是基于代理的,需要从代理对象调用方法才能生效。

解决方案

  1. 方式 1:使用 @Lazy 注解注入自身(推荐)

    1
    2
    3
    @Lazy
    @Autowired
    private DataBaseMetaService self;

    这样 self.byXXX() 实际是从代理对象调用,从而触发 AOP,确保 @DS 切换数据源生效。

  2. 方式 2:通过 AopContext 获取代理对象

    1
    2
    DataBaseMetaService proxy = (DataBaseMetaService) AopContext.currentProxy();
    proxy.byMaster(tableName);

    需要开启 exposeProxy = true,在 application.yml 配置:

    1
    2
    3
    4
    spring:
    aop:
    proxy-target-class: true
    expose-proxy: true

    但这种方式代码侵入性较强,不如方式 1 优雅。

  3. 方式 3:将 byXXX() 方法抽取到另一个 @Service。这样 @DS 标注的方法始终在被代理对象上执行,避免 this 调用导致 AOP 失效。

结尾

我这里选择第一种解决方案,注入自身。

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
/**
* 数据库元数据服务实现
*/
@Service
public class DataBaseMetaService implements IDataBaseMetaService {
// 延迟注入自身,确保 @DS 生效
@Lazy
@Autowired
private DataBaseMetaService self;

private final DataBaseMetaMapper dataBaseMetaMapper;

public DataBaseMetaService(DataBaseMetaMapper dataBaseMetaMapper) {
this.dataBaseMetaMapper = dataBaseMetaMapper;
}

/**
* 获取输入库表在数据库里的字段注释
*/
@Override
public List<DataBaseMateField> selectTableFieldsByScope(String dbName, String tableName) {
List<DataBaseMateField> dataBaseMetaFields = ListUtil.empty();
switch (dbName) {
case "xxx":
dataBaseMetaFields = self.byMaster(tableName);
break;
case "xxxxxx":
dataBaseMetaFields = self.byMonitorHm(tableName);
if (CollectionUtils.isEmpty(dataBaseMetaFields))
dataBaseMetaFields = self.byMonitorWce(tableName);
break;
default:
break;
}
return dataBaseMetaFields.stream().peek(field ->
field.setFieldName(StringUtil.toCamelCase(field.getFieldName()))
).collect(Collectors.toList());
}

@DS("a")
public List<DataBaseMateField> byMaster(String tableName) {}

@DS("b-a")
public List<DataBaseMateField> byMonitorHm(String tableName) {}

@DS("b-b")
public List<DataBaseMateField> byMonitorWce(String tableName) {}
}

window 端口占用但是查不到

作者 钟意
2025年3月7日 22:00

错误情景

  • 系统:
    • window11
    • docker run in wsl
  • 操作:
    • idea run nacos

错误诱因

Window 默认预留的 TCP 动态端口范围与需要启动的服务端口冲突导致。

所以查不来的原因是端口确实未使用,但是保留。

解决方案

  1. 查看windows保留端口序列是否在冲突范围,默认应该是1024开始,步长为13977。所以我nacos的8848、9848都在里面

    查看保留端口序列
    1
    netsh int ipv4 show dynamicport tcp
  2. 修改端口默认起始与步长,设置为自己不常用的区间。

  • start: 起始值
  • num: 长度
    修改保留端口序列
    1
    netsh int ipv4 set dynamicport tcp start=30000 num=13977

结尾

分享一个如果端口存在就kill端口的命令 e.g. killIf 8848

killIf
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
function killIf {
param (
[Parameter(Mandatory = $true)]
[int]$Port,

[switch]$l
)

try {
# 获取 netstat 输出并过滤与端口相关的信息
$processInfo = netstat -ano | Select-String -Pattern ":\b${Port}\b"

# 判空处理
if ($null -eq $processInfo -or $processInfo.Length -eq 0) {
Write-Host "端口 $Port 未被占用。" -ForegroundColor Yellow
Write-Host "提示:无法使用该端口,请检查是否有其他服务在使用,或尝试重启电脑。" -ForegroundColor Green
return
}

# 提取唯一的进程ID
$processIds = $processInfo | ForEach-Object { ($_ -split '\s+')[-1] } | Select-Object -Unique

foreach ($ProcId in $processIds) {
try {
# 确认 PID 是否为有效的数字
if ($ProcId -match '^\d+$') {
if ($l) {
Write-Host "端口 $Port 存在进程号: ${ProcId}" -ForegroundColor Yellow
}
# 尝试终止进程
taskkill /PID $ProcId /F
Write-Host "已终止进程 ${ProcId}, 释放端口 $Port 完毕。" -ForegroundColor Green
} else {
Write-Host "不正确的进程号: ${ProcId}" -ForegroundColor Red
}
}
catch {
Write-Host "终止进程 ${ProcId} 失败: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
catch {
Write-Host "发生错误: $($_.Exception.Message)" -ForegroundColor Red
}
}

它是一个​PowerShell 脚本​(扩展名为 .ps1),放到 $PROFILE 这个变量下面就行,直接在命令行输入 $PROFILE 有地址。

Coze同插件不同工具之间代码复用

作者 钟意
2024年12月21日 00:00

问题描述

用官方在线IDE的Node环境开发Coze插件的工具时,如果import复用其它模块定义好的函数、类、类型等,会出现类似如下报错:

1
2
3
Error: Cannot find module 'xxx'

ESLint couldn't find an eslint.config.(js|mjs|cjs) file.

问题已解决,急的话直接点击跳转到 最终方案 部分。

最终效果
最终效果

分析原因

毫无疑问,我们编写的IDE文件是一个ts文件,而Coze插件运行时是Node环境,Node环境运行时模块加载机制不能直接加载ts文件,因此需要先编译成js文件才能运行。

而编译过程中,如果遇到import语句,就会去查找对应的模块文件,但由于Node环境无法直接运行ts文件,因此会报错。

尝试解决

我大致思考尝试了如下方案

  • 方案一:修改配置,但是我们无法修改IDE的配置。
  • 方案二:用额外的包去支持ts文件,比如ts-nodets-node-dev等。但是我们不能控制命令行。
  • 方案三:用 const {xxx} = require('../common/common') 这种方式导入模块。但是这样会导致IDE没有注释提示且无法提示具体属性(导入的类型是一个any),无法自动补全。

经过一番挣扎,方案三是最佳可行方案,起码能解决基本的模块导入问题。最后我们要解决的是IDE的注释提示和自动补全问题,也就是编译时类型推断问题。

最终方案

虽然丑陋,但是好用。

  • 举例定义一个通用请求工具
通用工具
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
import { Args, Logger } from '@/runtime';
import axios, { AxiosInstance } from 'axios';

// 省略handle函数

// 基础响应类型
export type HttpRes<T> = {
code: number
message: string
result: boolean
data: T[]
}

// 定义通用请求工具
export abstract class BaseApi<T> {
private logger: Logger;
info(...args: any[]) {
this.logger.info(...args);
}
host: string = 'https://example.com';
constructor(baseUrl: string, logger: Logger){
this.api = axios.create({
baseURL: this.host + baseUrl,
headers: {
"Content-Type": "application/json"
}
});
this.logger = logger;
}
async get(url: string, params: Object): Promise<HttpRes<T> | null> {
const res = (await this.api.get(url, {params}))?.data
this.info(url, res, params, {count: res?.data?.length || null})
return res
}
// async post()
// ...
}

export type TOrder = {}
export class OrderApi extends BaseApi<TOrder> {
constructor(logger: Logger) {
super("/order", logger);
}
async getOrders(userId: string): Promise<HttpRes<TOrder> | null> {
return await this.get('/list', {userId})
}
}
// ... 省略其它API
  • 在其它工具代码中导入,并使用 typeof import() 去获取类型信息,这样IDE能自动补全提示。
需求工具导入
1
2
3
4
5
6
7
8
9
10
11
import { Args, Logger } from '@/runtime';
const { OrderApi }: { OrderApi: typeof import("../common/common").OrderApi } = module.require("../common/common");

export async function handler({ input, logger }: Args<Input>): Promise<Output> {
const userId = input.userId
const api = new OrderApi(logger)
const orders = await api.get('/order', {userId})
return {
data: orders
};
};

总结

typeof import 是 TypeScript 提供的静态类型推断工具,它在 编译阶段 就能捕捉模块的导出结构,而无需等到运行时去加载实际模块。
这一特性让我们能够应付 Coze 插件运行时环境中无法使用 import 的限制,在编译时获取类型信息,而不必依赖模块是否能被实际解析。

至于为何 require() 能支持动态导入,是因为做了一些拦截并转译工作,使得 require() 运行能支持动态导入。

总之,在Coze的IDE的Node环境中,使用运行时依赖得靠 require(),而在编译时得到依赖类型得靠 typeof import() 去做静态类型检查。

闲聊

好久没更新博客,都忘了怎么发布文章,有闲暇时候还是多写写保持思考与输出。

Pachelbel's Greatest Hit: The Ultimate Canon - 纪念帕海贝尔:终极卡农

作者 钟意
2024年8月13日 10:00

资源介绍

为了庆祝帕海贝尔350周年诞辰(1653/9/1),BMG唱片公司特别搜罗分佈在全球旗下的各领域知名乐手、乐团,以15首不同编曲、配器与演奏风格的卡农演出版本,来纪念这位音乐家。

专辑囊括了许多顶尖音乐家和极具时代意义的指标性演出版本,诸如在20世纪(1940年)第一个将“卡农”这首巴罗克杰作以大眾流行手法演出、带动古典音乐普及化功不可没的费德勒与他的小交响乐团;知名的法国百雅室内乐团的演出则是早期身历声录音时代(1970)广播电臺最热门的播放版本;长笛名家詹姆斯.高威自编自演的招牌长笛版,是一份保留了巴罗克原味的优雅版本;无与伦比的双钢琴演出,则是市调票选的人气首选版本;日本作曲家兼电子音乐大师富田勋的改编版,是最教人惊豔的现代版电子合成卡农;加拿大铜管乐团自编自演的管乐演出,展示出金属色泽的堂皇卡农;葛莱美奖女歌手克丽欧·莲恩的填词演唱版“何如、何处、何时”,成为风景独特的福音版;白金美声无伴奏重唱乐团展现了纯净圣洁的巴罗克正统;独一无二的“尺八与箏乐五重奏”版本巧妙地以东方古乐器呈现西方古乐;此外,被乐坛寄予厚望的英国古典吉他新秀克裏夫·卡洛首次面世的古典超技吉他独奏版;西班牙的古典吉他世家罗梅洛的吉他与音色合成器配合的协奏版,赋予出人意表的现代风貌;由伦敦市政厅弦乐团演出的终结乐章则忠实地以正统的巴罗克编制演出,让听眾亲炙原汁原味的正宗卡农。全专辑计有十轨全球首度重新编录版本,绝对能够满足无数卡农迷的重度搜藏欲。

曲目风格

管弦版永远的神,长笛的也很绝

  1. Canon in D 室内管弦版卡农
  2. Canon in D 长笛协奏版卡农
  3. Canon (Over a Basso Ostinato) 钢琴二重奏版卡农
  4. Canon of the Three Stars 现代版电子合成卡农
  5. How, Where, When? (Canon in D)
  6. Earth Angel - Williams 尘世天使
  7. Canon 铜管版卡农
  8. Canon in F F大调古乐器版卡农
  9. Variations on Pachelbel’s Canon in D 弦乐四重奏变奏版卡农
  10. Canon 超技吉他独奏版卡农
  11. Canon 美声无伴奏版卡农
  12. Sweet Home - Sakakibara, Dai
  13. Canon 吉他&音色合成器版卡农
  14. Canon in D 跨界乐团版卡农
  15. Canon & Gigue in D 卡农与吉格舞曲

资源分享

试听地址

网易云音乐

下载地址

TIPS

DSD类资源一般都比较大,对播放与监听设备可能要求较高。

❌
❌