包含关键字 typecho 的文章

一、 逃离英伟达围城:Cerebras 迎来的高光时刻

在硬件战略的棋局上,OpenAI 落下了一枚重磅且出人意料的棋子。他们正式推出了 GPT-5.3-Codex-Spark 模型,而这项发布最大的核心爆点不在于模型本身,而在于其运行的底座——这是 OpenAI 首款放弃传统英伟达 GPU,转而部署在 Cerebras 晶圆级芯片(Wafer Scale Engine 3 加速器)上的生产级 AI 模型

目前,基于 Cerebras 平台的 Codex‑Spark 已向 ChatGPT Pro 用户开放研究预览版。借由底层硬件的切换与系统级优化,该模型的运行速度达到了惊人的每秒 1000 个 Token。据 OpenAI 官方宣称,这一速度比早期版本快约 15 倍,能够为开发者带来极致流畅的实时交互式编码体验。

【笔者观点】
OpenAI 这一步棋极其“反常识”,却充满紧迫的战略威慑力。 当全行业仍在为英伟达的 H/B 系列算力卡抢破头时,奥特曼悄然将最吃吞吐量的生产级大模型搬到了 Cerebras 的晶圆级芯片上。这绝不仅仅是一次简单的硬件采购多元化,而是吹响了“推理侧去英伟达化”的号角。算力霸权的铜墙铁壁,正在被垂直场景(低延迟、高吞吐)的极速定制芯片硬生生撕开一道裂口。这也向全行业传递了一个明确的信号:大模型竞赛的下半场,推理成本与延迟的战争,GPU 已经不再是唯一解。

二、 速度与深度的博弈:重构代码生成的交互范式

为了实现真正的“实时交互式编码”,OpenAI 在 GPT-5.3-Codex-Spark 上进行了极其残酷的取舍。该模型被明确设定为优化低延迟与交互式编码工作流,战略性放弃了聚焦深度推理或通用任务。在 SWE-Bench Pro 和 Terminal-Bench 2.0 两项基准测试中,其性能仅介于 GPT-5.1-Codex-mini 与 GPT-5.3-Codex 之间,但耗时大幅缩减。

除了硬件加持,OpenAI 在软件栈底层的“狂飙”同样值得关注:引入持久化 WebSocket 连接、简化流式传输、重写推理栈关键代码。这套组合拳将客户端与服务器的单次往返开销暴降 80%,首 Token 生成时间(TTFT)腰斩 50%。

【笔者观点】
很多人陷入了“唯跑分论”的陷阱,认为牺牲模型的深度推理能力去换取速度是得不偿失的退步,这其实是一种极其短视的误判。 在真实的工程一线,开发者很多时候需要的不是一个思考一小时才能给出完美方案的“架构师”,而是一个能在一秒内完成 10 次语法修改、界面重构的“结对编程副手”。当首 Token 延迟压缩到人类近乎无感的毫秒级,人机协作的模式将从传统的“一问一答”彻底演变为“神经反射式”的共生。在特定场景下,极致的速度本身,就是一种颠覆性的智能。

三、 公关数字的泡沫与开发者的真实倒戈

尽管 OpenAI 宣称了极其华丽的提速数据,但这场“狂飙”在开发者社区却引发了剧烈的两极分化与质疑。

一方面,极客社区的声音显得格外犀利。Reddit 用户 Tystros 明确表达了对“降智提速”的抵触:“如果完成任务需要一小时但结果更好,我愿意等一小时”;而另一派用户(如 stobak)则认为,超快模型能大幅削减反复试错迭代带来的隐性累积成本。

更致命的打假来自 X 平台的研究者 Nicholas Van Landschoot。他毫不客气地戳破了“提速 15 倍”的公关泡沫——在实际基准测试中,真实性能提升仅接近 1.37 倍。所谓的 15 倍,只是 OpenAI 玩了一个偷换概念的把戏:他们拿新模型去对比了旧版 Codex 中一个刻意延长推理时间以提升准确性的特殊配置(x-high)。

【笔者观点】
永远不要对硅谷的公关修辞照单全收。 1.37 倍的真实提升与 15 倍的宣传口径之间,折射出的是 AI 巨头在技术瓶颈期急于制造里程碑的焦虑。更危险的信号在于用户口碑的撕裂:天下武功唯快不破的前提,是你的方向得对。如果模型生成的代码充满了逻辑漏洞,那 1000 Token/秒的速度不过是在“加速制造工业垃圾”。OpenAI 必须清醒地认识到,Cerebras 带来的速度狂欢,只是一剂缓解交互阵痛的“局部止痛药”,它无法掩盖大模型在复杂推理上进化的停滞。GPU 依然是核心,而在速度与智能的钢丝绳上,稍有不慎,就会跌入虚假繁荣的深渊。

👇 欢迎关注我的公众号

在 AI 爆发的深水区,我们一起探索真正能穿越周期的技术价值。
微信搜索 【睿见新世界】 或扫描下方二维码,获取每周硬核技术推文:

微信图片_20260301232734_225_35.jpg

欢迎关注【睿见新世界】

结构决定功能,历史揭示设计。本文从用户视角出发,向底层追问"它是怎么做到的"。

OpenClaw 是什么?

你在任何聊天窗口给它发一条消息,它就能帮你操作电脑——执行命令、读写文件、浏览网页、操控桌面应用、管理定时任务,甚至语音对话。

和常见的 AI 聊天机器人不同,OpenClaw 运行在你自己的电脑上,不依赖云端服务器。它支持 WhatsApp、Telegram、Discord、Slack、Signal、iMessage 等海外主流平台,也通过插件支持飞书、企业微信、qq等国内渠道。除了消息平台,还有 macOS / iOS / Android 原生应用,以及终端命令行和 Web 控制台。

简单说:20+ 种入口,一个本地 AI 大脑,一套工具集。

20+ 种入口,一个本地 AI 大脑,一套工具集.png


演化史

OpenClaw 历经 Warelay → Clawdis → Clawdbot → OpenClaw 四次更名,我们看看每次更名时的架构变化。

Warelay:"一条管道"

项目名 warelay = WA Relay(WhatsApp 中继)。用户通过 WhatsApp 或短信给 AI 发消息,收到文字回复。没有 Gateway、没有 Agent、没有会话管理——就是一个 webhook 脚本。

一条管道.png

关键选择:用 Baileys(开源 WhatsApp 协议库)而非商业 API 收发 WhatsApp。好处是免费且不依赖第三方服务,代价是 Baileys 要求每台机器只能维持一个 WhatsApp 会话

Clawdis:最关键的跃迁

这是变化最剧烈的阶段——三件大事同时发生:

引入 Pi SDK:Pi 是一个外部 agent 框架,提供了"消息 → prompt → 调大模型 → 解析工具调用 → 执行 → 循环"的核心 agent 循环(架构详见后文 Pi Agent Runtime 一节)。OpenClaw 从此不再是"收到消息调一次 API",而是一个真正的 AI agent。

2 周内接入 5 个渠道:Telegram、Discord、Signal、iMessage、WhatsApp——每个平台的消息格式、API 风格、群组概念都不同。多渠道的差异催生了 Adapter 模式Channel Dock(统一注册中心),每个渠道只实现它需要的接口子集(详见后文通道适配器一节)。

Gateway 诞生:从一个 CLI 命令行工具变为常驻后台服务,所有客户端通过 WebSocket 统一接入,内含消息路由、队列、会话管理、Cron 调度、Hook 系统和 Plugin 注册。

Clawdbot → OpenClaw:生产化与平台化

核心思路是让新功能通过插件生长,核心代码库不再膨胀。为此落地了三层扩展机制:

插件系统:Plugin SDK + jiti

社区开发插件时,导出一个 register(api) 函数即可声明能力——可注册的类型包括:

  • 渠道registerChannel)—— 接入新的消息平台
  • 工具registerTool)—— 给 AI 新的操作能力
  • 钩子registerHook)—— 在消息流水线的特定节点插入逻辑
  • HTTP 路由 / CLI 子命令 / 后台服务 —— 扩展 Gateway 和命令行

Node.js 生态的模块格式分裂(ESM vs CJS)是插件加载的主要障碍。OpenClaw 用 jiti(运行时 TypeScript 编译加载器)统一处理:插件不需要预编译,写完直接安装即可运行。目前 40+ 个扩展(飞书、LINE、Matrix、Twitch、语音通话……)都以插件形式存在。

本地记忆:sqlite-vec

让 AI 拥有跨会话的长期记忆。文本切片后转为向量,存入本地 SQLite,用 sqlite-vec 扩展做余弦相似度检索。搜索采用混合策略——向量语义匹配 + BM25 关键词匹配——兼顾"意思相近"和"关键词命中"。所有数据留在本地磁盘,也可通过 MCP 桥接对接外部知识库。

技能市场:ClawHub

插件解决了渠道和工具的扩展,但 AI 的行为模式怎么共享?ClawHub(clawhub.ai)是公开的技能注册中心。技能本质是注入 system prompt 的声明文件,描述 AI 在特定场景下该怎么做(比如"操控 macOS 桌面"、"生成代码后自动运行测试")。

  • 安装:clawhub install <slug>
  • 加载优先级:workspace > 本地 > 内置
  • 社区治理:点赞、评论、举报,有独立审核机制

项目的 VISION.md 明确要求:新技能应先发布到 ClawHub,不要默认加入核心仓库


核心子系统拆解

Pi Agent Runtime:OpenClaw 的大脑

OpenClaw 的 agent 能力不是从零自研——它站在 Pi SDK 的肩膀上。Pi 是一个 7 包 monorepo,从底向上分三层:

OpenClaw 的大脑.png

pi-ai(LLM 抽象层) 把 16+ 家模型供应商统一成一个 stream(model, context) 调用。每个供应商实现一个 StreamFunction,把各家私有的流式响应转成标准事件序列(text_deltathinking_deltatoolcall_start/end)。模型注册表是自动生成的,包含每个模型的成本、上下文窗口、支持的输入类型(文本/图片)和推理能力。

pi-agent-core(Agent 运行时) 提供核心循环:用户消息进入 → 流式调用 LLM → 解析工具调用 → 按序执行工具 → 结果返回 LLM → 继续循环直到 end_turn。工具用 TypeBox 定义参数 schema,运行时自动校验。。

pi-coding-agent(SDK 层) 提供 createAgentSession() 工厂方法,一次性组装工具集、上下文钩子和会话存储。SessionManager 用 JSONL 文件存储对话树(每条消息有 id + parentId),支持分支、恢复和压缩。上下文管线分两步:transformContext() 在 AgentMessage 层面裁剪/注入(比如删掉过旧的消息),convertToLlm() 再把自定义消息类型转成 LLM 能理解的标准格式。

OpenClaw 在 Pi 之上包了六部分,让它从一个通用 agent 框架变成多渠道 AI 助手:

OpenClaw 在 Pi .png

消息全链路:从收到到回复

一条消息从进入系统到收到回复,经过这样的流水线:

消息全链路:从收到到回复.png

两个值得注意的设计:

  • Steering(转向):agent 正在生成回复时,你又发了一条消息。普通系统会让你等上一条处理完。OpenClaw 通过 session.steer() 把新消息实时注入当前 agent 运行,AI 会立刻调整回复方向——就像你跟人说话时补了一句"等等,我改主意了"。
  • 仿人延迟:回复不是一次性弹出,而是分块发送,块间插入随机间隔,模拟真人打字节奏。在微信、Telegram 这类即时通讯里,这让对话体验更自然。

通道适配器:如何统一 20+ 种渠道

WhatsApp 有"已读回执",Telegram 有"群组管理 API",Discord 有"权限体系",Signal 几乎什么管理接口都没有——每个平台的能力完全不同。

OpenClaw 的做法不是设计一个大而全的接口让每个通道都实现,而是拆成 10 种细粒度 Adapter,每个通道只实现它需要的子集

10 种细粒度 Adapter.png

  • WhatsApp 实现了 HeartbeatAdapter(Baileys 连接不稳定,需要心跳保活)
  • Discord 实现了 SecurityAdapter(有复杂的权限体系)
  • Signal 不需要 GroupAdapter(没有群管理 API)

社区开发新渠道插件(比如飞书、LINE)时,用 Plugin SDK 实现同样的接口即可接入,不需要改动 OpenClaw 核心代码。

Peekaboo Bridge:让 AI 看见并操控 macOS 桌面

你在 WhatsApp 里说"帮我看看屏幕上是什么",AI 就能截屏、理解画面内容、告诉你答案。你说"点击登录按钮",它就能找到按钮并点击。你说"打开 Safari 访问某个网址",它能启动应用、导航页面、在输入框里打字。

这背后是 Peekaboo——一个 macOS 桌面自动化工具,让 AI 拥有"眼睛"和"手":

  • :截屏、录屏、标注 UI 元素(给每个按钮/输入框分配 ID),配合 AI 视觉分析理解画面
  • 操作:点击、打字、按键组合、滚动、拖拽,支持定位到具体 UI 元素
  • 管理:启动/切换/关闭应用,管理窗口大小和位置,切换 macOS 空间,读写剪贴板

典型工作流是:截屏标注 → AI 识别元素 ID → 点击/输入 → 再截屏确认结果,全程在聊天窗口完成。

Peekaboo 内部分为五层:

Peekaboo 内部分为五层.png

几个关键设计:

Bridge 权限代理:macOS 的屏幕录制和辅助功能权限是按应用授予的,但 AI agent 调用的是命令行工具 peekaboo。解决方案是 Bridge 架构——CLI 通过 UNIX Socket 连接到 OpenClaw.app 内的 BridgeHost 进程,BridgeHost 先验证调用方的代码签名和 Team ID,通过后才借出 app 已获得的权限执行操作。用户只需在系统设置里授权一次 OpenClaw.app。

元素检测与编号see 命令截屏时同步遍历 Accessibility 树(通过 AXorcist 封装),把每个可交互元素按类型编号——按钮 B1、B2,输入框 T1、T2。遍历有严格限制(深度 8 层、子节点 200 个、150ms 超时),检测结果缓存 1.5 秒避免重复遍历。后续 click B1 就能直接定位到目标元素。

内置 Agent 模式:Peekaboo 自带基于 Tachikoma 的 agent 循环(支持 OpenAI / Anthropic / Ollama 等模型),可以独立执行多步桌面任务——不经过 OpenClaw,直接 peekaboo agent "打开备忘录写一条待办" 就能截屏→分析→点击→输入→确认,循环直到完成。

Skill 注入:Peekaboo 在 OpenClaw 中不是代码依赖,而是一个 Skill——通过声明文件标注"仅限 macOS、需要 peekaboo 命令",运行时按需注入到 AI 的 system prompt 中。非 macOS 用户完全不会感知到它的存在。

Cron 调度器:不只是定时器

OpenClaw 的定时任务不是简单的 setInterval,而是一个生产级调度系统:

  • SHA256 Stagger:如果设了 100 个"每天早上 9 点"的定时任务,不会同时触发——用每个任务 ID 的 SHA256 哈希做确定性偏移(默认在 5 分钟窗口内分散),避免所有任务同一秒涌入,导致 CPU 和 API 调用瞬间过载
  • 两种执行模式systemEvent(把消息注入主会话,像用户发了一条消息)和 agentTurn(启动独立的 agent 运行,互不干扰)
  • 容错:失败后指数退避重试(30 秒 → 1 分钟 → 5 分钟 → 15 分钟 → 1 小时),连续失败触发告警,临时 session 自动回收

Context Engine:长对话不丢记忆

大模型有上下文窗口限制(比如 200K token),聊久了上下文就超出。Pi SDK 提供了底层的两步上下文管线(transformContext 裁剪 + convertToLlm 格式转换),OpenClaw 在此基础上实现了自己的 Context Engine,定义了 5 个生命周期钩子:

bootstrap(初始化)→ ingest(新消息进入)→ assemble(组装发给模型的上下文)→ compact(上下文太长时压缩)→ afterTurn(一轮对话结束后清理)

默认实现用 token 计数 + LLM 摘要来压缩历史对话。但整个 Context Engine 是可插拔的——社区可以写一个完全不同的实现(比如基于向量检索的长期记忆),替换进去不需要改一行核心代码。


结语

阿瑟·克拉克在《2001:太空漫游》里写过一个场景:猿人月亮观察者捡起一块石头,第一次意识到自己的手可以延伸。从那一刻起,工具就不再是身体之外的东西——它是意志的投射。

我们今天拆解的这些——Gateway、Adapter、Bridge、Agent Loop——本质上都是同一件事的不同切面:让意图穿透介质。用户在 WhatsApp 里打一行字,意图穿过消息协议、穿过归一化流水线、穿过 LLM 的概率空间、穿过工具调用,最终变成屏幕上的一次点击、磁盘上的一个文件、终端里的一行输出。中间的每一层抽象,都是为了让这条路径上的摩擦再少一点。

这让人想起维纳在《控制论》里的判断:智能的本质不是计算,而是与环境之间有效的信息交换。OpenClaw 做的事情,与其说是"让 AI 用工具",不如说是在缩短人的意图世界的状态之间的距离。

2Libra 现在引入了 Pro 与站内推广功能。具体的介绍入口为: https://2libra.com/pro/about ,或可在页脚处看到这个入口。

2Libra Pro 现在可以通过 Ko‑fi 赞助手动开通:你可以在 Ko‑fi 页面进行一次性或多次赞助,满足门槛后,2Libra 会为你的账号标记 Pro 身份。

在 2Libra 推广你的作品

Pro 会员可以在 2Libra 通过自助推广系统,用更温和、可控的方式,把自己的产品、项目或内容长期展示给用户。

你可以为同一个作品创建多种不同风格的展示卡片,然后系统会按推广权重在页面右侧的专属位置展示。

可选展示形式

  1. 简洁文字卡片:适合一句话介绍你的作品或活动。
  2. 小图 + 文本(1:1):突出 Logo 或产品界面。
  3. 横幅展示(3:1):适合宣传短期活动或醒目 Slogan。
  4. 大图 + 文本 + 按钮(4:3):更完整地讲清亮点,并附上「去了解」等跳转按钮。

展示处理

每一位 Pro 会员在过去 365 天内的赞助金额,会被折算成「推广权重」。同一会员可以创建多个活动,系统会把 TA 的总权重平均分配到这些活动上,再与全站所有 Pro 会员的权重一起参与轮播。

后续

后续会补充一些 Pro 独有的功能,若各位觉得 2Libra 不错的话,赞助支持一下最好了thanks,目前的第一个目标是升级服务器配置feel_wronged

一、行业背景:免费证书的“有效期缩短”与“国密合规”双重压力

2026年,全球SSL证书行业迎来重大变革:国际主流CA机构(如DigiCert)将免费证书有效期从90天缩短至200天,且需通过订阅服务自动续期。这一调整虽降低了运维频率,但长期成本和管理复杂度仍高于一年期证书。与此同时,中国《密码法》《等保2.0》等法规要求政务、金融、教育等关键领域必须采用国密算法(SM2/SM3/SM4) ,推动国产SSL证书市场快速增长。

在此背景下,免费SSL证书呈现两大分化趋势

  1. 国际证书:有效期缩短、自动化管理普及,但算法仍以RSA为主,难以满足国密合规需求。
  2. 国产证书:以JoySSL为代表的平台提供一年期免费国密证书,支持政务、教育等场景,且兼容主流浏览器。

二、国密算法:免费证书的“安全升级”与“合规刚需”

1. 国密算法的核心优势
  • 自主可控:SM2算法基于椭圆曲线密码学,256位密钥强度等同于RSA 2048位,且计算效率更高,抗量子计算攻击能力更强。
  • 政策强制要求:政务系统、金融平台等必须通过等保测评,国密SSL证书是密评(商用密码应用安全性评估)的核心指标之一。
  • 数据完整性保障:SM3哈希算法抗碰撞性优于SHA-256,可防止政务数据在传输中被篡改。
2. 国密证书的适用场景
  • 政务网站:政府官网、公共服务平台,需展示安全锁和企业名称,提升公信力。
  • 教育系统:学校官网、教务系统,保障学生信息传输安全。
  • 金融行业:银行、支付平台,满足监管对国产密码技术的要求。
3. 免费国密证书的突破

传统国密证书价格高昂,但JoySSL在2026年仍为政务及教育单位提供一年期免费国密证书,支持以下特性:

  • 双算法兼容:自动适配国密浏览器(如360、红莲花)和国际浏览器(Chrome、Firefox),通过“SM2+RSA”双证书模式解决兼容性问题。
  • 长期有效:有效期365天,避免频繁续期。
  • 自主可控:支持SM2、SM4等国密算法,满足等保合规需求。

三、免费证书申请全流程:以JoySSL为例

1. 注册账号与解锁权益
  • 访问JoySSL官网,点击注册新账号。
  • 在注册页面填写邀请码“230959” ,即可解锁以下权益:

    • 免费领取一年期政务/教育版国密SSL证书。
    • 享1次免费证书安装技术支持(价值200元)。
    • 证书有效期延长至365天(普通免费版为90天)。
2. 申请证书
  • 登录后,在证书选购页面选择“政务版”或“教育版”免费SSL证书。
  • 输入需保护的域名(如gov.example.comedu.example.com)。
  • 填写单位信息(需与证明文件一致),如政府单位需提供组织机构代码证,教育机构需提供学校名称。
3. 验证域名所有权
  • 推荐DNS验证:在域名管理后台添加一条TXT解析记录(如_acme-challenge.example.com的TXT值)。
  • 返回JoySSL后台点击“验证”,等待状态更新为“已签发”。
4. 人工审核与签发
  • 政务/教育专版需人工审核单位资质,审核通过后将收到邮件通知。
  • 登录后台下载证书文件(包含.crt.key文件)。
5. 安装证书
  • Nginx配置示例
nginx
  server {
      listen 443 ssl;
      server_name example.com;
      ssl_certificate /etc/nginx/ssl/example.com.crt;
      ssl_certificate_key /etc/nginx/ssl/example.com.key;
      ssl_protocols TLSv1.2 TLSv1.3;
      ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
  }
  
  • 重启Nginx生效:systemctl restart nginx

四、免费证书的“隐藏价值”:长期运维支持

1. 自动续期与到期提醒
  • JoySSL提供自动续期功能,证书到期前30天通过邮件和短信提醒。
  • 用户可在后台设置“自动续期”,系统将自动重新验证域名并签发新证书。
2. 技术支持与故障排查
  • 免费技术支持:输入注册码“230959”的用户可享1次免费安装指导,解决混合内容、证书链不完整等问题。
  • 在线工具检测:通过SSL Labs测试工具检测证书配置,确保评级达A级以上。

五、未来展望:国密证书的普及与免费化的平衡

1. 政策驱动市场增长
  • 政务领域国密覆盖率已超50%,金融行业核心系统覆盖率超60%,教育、医疗等领域加速推进。
  • 密评不合格单位将面临业务限行,倒逼更多机构采用国密证书。
2. 技术突破降低成本
  • 国产证书价格比国际品牌低30%-50%,运营商入局推动中小企业普惠化部署。
  • 自动化证书管理平台普及,ACME协议支持国密OV/EV证书签发,部署周期缩短至2小时内。
3. 免费证书的“合规转型”
  • 免费国际证书(如Let’s Encrypt)仍以RSA算法为主,难以满足国密需求。
  • JoySSL的免费国密证书填补了市场空白,为政务、教育单位提供低成本合规方案。


在这个所有人都以为大模型就是终局的狂热时刻,管理着超900亿美元资产的Insight Partners联合创始人Jerry Murdock,却向科技界泼下了一盆极其刺骨的冷水:不要再把AI当成辅助人类的工具,真正的海啸是“自主智能体(Autonomous Agents)”。

在这场即将到来的海啸中,传统的SaaS逻辑将被彻底撕裂,连当红炸子鸡Cursor都面临淘汰,而英伟达的无敌铁幕也已经出现了裂痕。

一、 估值270亿的Cursor,可能已经是一只“恐龙”

在Murdock最近接触的众多真正具备AI原生思维的初创公司中,大家达成了一个令人毛骨悚然的共识:Cursor的产品已经过时了。

像E2B、Eventual等使用OpenClaw或NanoClaw构建自主智能体系统的初创公司,仅仅花了2到6周的时间,就将完全自主的编程智能体投入了实际运行。它们不需要人类在键盘前一行行审查、回车,而是直接自主完成代码生成与部署。

【笔者观点】
“人类在环(Human-in-the-loop)”的工具,注定只是过渡期的安慰剂。
当下的科技圈对Cursor顶礼膜拜,却忽略了一个致命的反常识:如果你的AI工具还需要人类去充当“点击器”和“决策者”,那它就无法实现真正的指数级爆发。Cursor如果不能在短期内迅速斩断对“人类开发者”的依赖,全面拥抱自主智能体,它高达270亿美元的估值将迅速沦为虚幻的泡沫。

二、 B2B已死,未来是B2A(Business-to-Agent)的天下

传统的软件采购流程是怎样的?销售人员请客吃饭、制作精美的PPT、展示炫酷的用户界面(UI),最后由人类高管签字买单。

但在Murdock的预判中,这一切即将作废。未来的软件和技术服务,不再是由人类采购,而是由企业雇佣的“自主智能体”进行自动评估、试用和决策。 智能体没有喜好,不吃画饼,它们会在毫秒级的时间内开启几万个沙箱环境,直接测试哪家软件的API响应最快、成本最低。比如E2B响应仅需70毫秒,智能体会毫不犹豫地抛弃那些响应需要400毫秒的传统巨头。

【笔者观点】
软件行业的“颜值时代”宣告终结,极致的底层效率将成为唯一通行证。
这意味着全球数百万的SaaS销售、UI/UX设计师将面临灭顶之灾。当你未来的客户是一个不懂人情世故、只看重并行计算能力和Token性价比的AI时,你所有的营销话术都将沦为废话。企业如果不立刻转向“为智能体开发软件(B2A)”,最多一年半后,连留在牌桌上的资格都没有。

三、 英伟达的护城河,并非坚不可摧

在这场智能体革命中,底层算力的格局也将被重写。Murdock指出,未来的智能体将形成一个“编排层”(类似当年的LAMP架构)。它们会极其精明地分发工作流:复杂的推理丢给昂贵的闭源模型(如Claude),而大量的基础任务则分配给开源模型(如DeepSeek、Llama)。

这种精准的算力分发,将极大地刺激ASIC(专用集成电路)芯片的爆发。因为ASIC在处理特定工作负载时,成本远低于英伟达昂贵的通用GPU。Meta等巨头敢于对黄仁勋说“不”,正是因为他们将赌注压在了ASIC芯片上。

【笔者观点】
天下苦“算力霸权”久矣,智能体将成为瓦解英伟达垄断的第一把尖刀。
AI绝不会为品牌溢价买单。当自主智能体掌控了模型调度权,它们会像最苛刻的精算师一样榨干每一滴算力性价比。英伟达收购Groq是在拼命修补护城河,但芯片硬件被“商品化”、“管道化”的历史宿命,或许连黄仁勋也无法彻底逆转。

四、 员工数量,正在从“资产”沦为“负债”

Murdock抛出了一个极度刺耳的现实:企业会先砍掉“下一个准备招的人”。任何向系统中输入数据的工作、行政、营销甚至初级程序员,都将被自主智能体取代。这并非天方夜谭,小微企业将率先开始这场冷酷的替换,因为AI不需要事假、病假,更没有千禧一代过剩的自我意识。

未来十年,“十亿美元估值、只有一名人类员工”的超级个体公司将不再是神话,而是常态。而随之而来的,是劳动力市场的剧烈震荡,全民基本收入(UBI)将在未来两到三年内被迫摆上大选的谈判桌。

【笔者观点】
一场没有硝烟的“白领大清洗”已经鸣枪。
过去,庞大的团队规模是企业实力的象征,而在AI时代,庞大的人类团队意味着低效、高廉、决策迟缓,是纯粹的“负债”。当智能体正式挂上工牌成为你的“新同事”时,不要问它能帮你做什么,而要问自己:在它眼里,你还有什么不可替代的价值?如果你只会做“流程的搬运工”,那么你的职业倒计时,现在已经开始了。

👇 欢迎关注我的公众号

在 AI 爆发的深水区,我们一起探索真正能穿越周期的技术价值。
微信搜索 【睿见新世界】 或扫描下方二维码,获取每周硬核技术推文:

微信图片_20260301232734_225_35.jpg

欢迎关注【睿见新世界】

流与块

Standard IO是对字节流的读写,在进行IO之前,首先创建一个流对象,流对象进行读写操作都是按字节 ,一个字节一个字节的来读或写。而NIO把IO抽象成块,类似磁盘的读写,每次IO操作的单位都是一个块,块被读入内存之后就是一个byte[],NIO一次可以读或写多个字节。

I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

面向流的 I/O 一次处理一个字节数据: 一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。

面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。

Java对IO多路复用的支持

NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。但其实应该叫new IO,是相较于传统IO来说的。

Java NIO 中的 Selector 类是基于操作系统提供的 I/O 多路复用机制实现的,而在 Linux 上,这个机制就是 epoll

关于触发模式

  1. Java NIO 的 Selector 默认使用的是水平触发模式(Level-Triggered, LT)。这意味着当一个文件描述符(在 Java 中通常是 SocketChannelServerSocketChannel)变得可读或可写时,Selector 会持续通知,直到该文件描述符上的事件被处理。这与 epoll 的水平触发模式是一致的。
  2. 虽然 epoll 也支持边缘触发模式(Edge-Triggered, ET),但 Java NIO 的 Selector 并没有直接提供对边缘触发模式的支持。如果需要使用边缘触发模式,通常需要直接使用底层的系统调用(如通过 JNI 调用 epoll 的边缘触发模式),但这超出了标准 Java NIO 库的范围。

关于水平触发和边缘触发的区别可以看这篇文章,总结一下:

  • Java NIO 在 Linux 上使用 epoll 作为底层的 I/O 多路复用机制。
  • Java NIO 的 Selector 默认使用 epoll 的水平触发模式。
  • Java NIO 不直接支持 epoll 的边缘触发模式,需要通过其他方式实现。

因此,如果在 Linux 上使用 Java NIO 的 Selector,它使用的是 epoll 的水平触发模式

三大组件

通道

被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。一个通道会有一个专属的文件状态描述符。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据。

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。

JAVA NIO 框架中,自有的Channel通道包括:

所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。如上图所示

  • FileChannel: 从文件中读写数据;
  • DatagramChannel: 通过 UDP 读写网络中数据;
  • SocketChannel: TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP: 端口 到 服务器IP: 端口的通信连接。
  • ServerSocketChannel: 应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。

FileChannel 是磁盘IO的通道,后三个是网络IO的通道。并且FileChannel不能切换为非阻塞模式,因此FileChannel不适合Selector。

缓冲区

数据缓存区: 在JAVA NIO 框架中,为了保证每个通道的数据读写速度JAVA NIO 框架为每一种需要支持数据读写的通道集成了Buffer的支持。用于读取或写入数据到通道。

这句话怎么理解呢? 例如ServerSocketChannel通道它只支持对OP_ACCEPT事件的监听,所以它是不能直接进行网络数据内容的读写的。所以ServerSocketChannel是没有集成Buffer的。

Buffer有两种工作模式: 写模式和读模式。在读模式下,应用程序只能从Buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦您决定要从Buffer中读取数据,一定要将Buffer的状态改为读模式。

发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。

缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区包括以下类型:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

ByteBuffer 正确使用姿势

  1. 向 buffer 写入数据,例如调用 channel.read(buffer)
  2. 调用 flip() 切换至读模式
  3. 从 buffer 读取数据,例如调用 buffer.get()
  4. 调用 clear() 或 compact() 切换至写模式
  5. 重复 1~4 步骤

ByteBuffer 大小分配:

  • 每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer
  • ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer

    • 一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现 http://tutorials.jenkov.com/java-performance/resizable-array.html
    • 另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗

缓冲区状态变量

  • capacity: 最大容量;
  • position: 当前已经读写的字节数;
  • limit: 还可以读写的字节数。

状态变量的改变过程举例:

① 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。

② 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 移动设置为 5,limit 保持不变。

③ 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。
写到输出通道,意味着要从buffer中读出,才能写入channel

public Buffer flip() {
     limit = position;
     position = 0;
     mark = -1;
     return this;
}

④ 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。

⑤ 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。

⑥ compact 方法,是把未读完的部分向前压缩,然后切换至写模式

文件 NIO 实例

以下展示了使用 NIO 快速复制文件的实例:

public static void fastCopy(String src, String dist) throws IOException {

    // 获得源文件的输入字节流
    FileInputStream fin = new FileInputStream(src);

    // 获取输入字节流的文件通道
    FileChannel fcin = fin.getChannel();

    // 获取目标文件的输出字节流 
    FileOutputStream fout = new FileOutputStream(dist);

    // 获取输出字节流的通道
    FileChannel fcout = fout.getChannel();

    // 为缓冲区分配 1024 个字节
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

    while (true) {

        // 从输入通道中读取数据到缓冲区中
        int r = fcin.read(buffer);//对于buffer来说,这是写入的过程

        // read() 返回 -1 表示 EOF
        if (r == -1) {
            break;
        }

        // 切换读写
        buffer.flip();

        // 把缓冲区的内容写入输出文件中
        fcout.write(buffer);//对于buffer来说,这是读取的过程
        
        // 清空缓冲区
        buffer.clear();
    }
}

选择器

Selector (选择器,多路复用器)是JavaNIO 中能够检测一到多个NIO通道,是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

NIO 实现了 IO 多路复用中的 多Reactor多进程/线程 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。

因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件具有更好的性能。

  • 事件订阅和Channel管理:应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。以下代码来自WindowsSelectorImpl实现类中,对已经注册的Channel的管理容器:
// Initial capacity of the poll array
private final int INIT_CAP = 8;
// Maximum number of sockets for select().
// Should be INIT_CAP times a power of 2
private final static int MAX_SELECTABLE_FDS = 1024;

// The list of SelectableChannels serviced by this Selector. Every mod
// MAX_SELECTABLE_FDS entry is bogus, to align this array with the poll
// array,  where the corresponding entry is occupied by the wakeupSocket
private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[INIT_CAP];
  • 轮询代理:应用层不再通过阻塞模式或者非阻塞模式直接询问操作系统“事件有没有发生”,而是由Selector代其询问。
  • 实现不同操作系统的支持:多路复用IO技术 是需要操作系统进行支持的,其特点就是操作系统可以同时扫描同一个端口上不同网络连接的事件。所以作为上层的JVM,必须要为 不同操作系统的多路复用IO实现 编写不同的代码。同样测试环境是Windows,它对应的实现类是sun.nio.ch.WindowsSelectorImpl:

selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic)

创建选择器

Selector selector = Selector.open();

绑定 Channel 事件

也称之为注册事件,绑定的事件 selector 才会关心

ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

Channel必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。

在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

它们在 SelectionKey 的定义如下:

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

监听事件

  • 方法1,阻塞直到绑定事件发生
int count = selector.select();
  • 方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)
int count = selector.select(long timeout);
  • 方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件
int count = selector.selectNow();

使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。

那 select 何时不阻塞:

  • 事件发生时

    • 客户端发起连接请求,会触发 accept 事件
    • 客户端发送数据过来,客户端正常、异常关闭时,都会触发 read 事件,另外如果发送的数据大于 buffer 缓冲区,会触发多次读取事件
    • channel 可写,会触发 write 事件
    • 在 linux 下 nio bug 发生时
  • 调用 selector.wakeup()
  • 调用 selector.close()
  • selector 所在线程 interrupt

处理accept事件

// 获取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    // 判断事件类型
    if (key.isAcceptable()) {
        ServerSocketChannel c = (ServerSocketChannel) key.channel();
        // 必须处理
        SocketChannel sc = c.accept();
        sc.configureBlocking(false);
        sc.register(selector, SelectionKey.OP_READ);
        // ...
    } 
    // 处理完毕,必须将事件移除
    keyIterator.remove();
}

事件发生后,能否不处理?
不能,事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发

这里为什么要 keyIterator.remove() 操作?
因为 select 在事件发生后,就会将相关的 key 放入 selectedKeys 集合,但不会在处理完后从 selectedKeys 集合中移除,需要我们自己编码删除。例如

  • 第一次触发了 ssckey 上的 accept 事件,没有移除 ssckey
  • 第二次触发了 sckey 上的 read 事件,但这时 selectedKeys 中还有上次的 ssckey ,在处理时因为没有真正的 serverSocket 连上了,就会导致空指针异常

处理 read 事件

// 获取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    // 判断事件类型
    if (key.isAcceptable()) {
        ServerSocketChannel c = (ServerSocketChannel) key.channel();
        // 必须处理
        SocketChannel sc = c.accept();
        sc.configureBlocking(false);
        sc.register(selector, SelectionKey.OP_READ);
        // ...
    } else if (key.isReadable()) {
        SocketChannel sc = (SocketChannel) key.channel();
        //实际使用中,不会一次给buffer缓冲区分配太多空间,因此可能存在粘包的问题
        ByteBuffer buffer = ByteBuffer.allocate(128);
        int read = sc.read(buffer);
        if(read == -1) {
            key.cancel();
            sc.close();
        } else {
            buffer.flip();
        }
    }
    // 处理完毕,必须将事件移除
    keyIterator.remove();
}

cancel 的作用? cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件

处理消息的边界
sequenceDiagram 
participant c1 as 客户端1
participant s as 服务器
participant b1 as ByteBuffer1
participant b2 as ByteBuffer2
c1 ->> s: 发送 01234567890abcdef3333\r
s ->> b1: 第一次 read 存入 01234567890abcdef
s ->> b2: 扩容
b1 ->> b2: 拷贝 01234567890abcdef
s ->> b2: 第二次 read 存入 3333\r
b2 ->> b2: 01234567890abcdef3333\r
// 获取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    // 判断事件类型
    if (key.isAcceptable()) {
        ServerSocketChannel c = (ServerSocketChannel) key.channel();
        // 必须处理
        SocketChannel sc = c.accept();
        sc.configureBlocking(false);
        ByteBuffer buffer = ByteBuffer.allocate(16); // attachment
        // 将一个 byteBuffer 作为附件关联到 selectionKey 上
        SelectionKey scKey = sc.register(selector, 0, buffer);
        scKey.register(selector, SelectionKey.OP_READ);
    } else if (key.isReadable()) { // 如果是 read
        try {
            SocketChannel sc = (SocketChannel) key.channel();
            // 获取 selectionKey 上关联的附件
            ByteBuffer buffer = (ByteBuffer) key.attachment();
            int read = sc.read(buffer);
            if(read == -1) {
                key.cancel();
            } else {
                split(buffer);
                // 需要扩容
                if (buffer.position() == buffer.limit()) {
                    ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
                    buffer.flip();
                    newBuffer.put(buffer); // 0123456789abcdef3333\n
                    key.attach(newBuffer);
                }
            } catch (IOException e) {
                e.printStackTrace();
                key.cancel();  // 因为客户端断开了,因此需要将 key 取消(从 selector 的 keys 集合中真正删除 key)
            }
    }
    // 处理完毕,必须将事件移除
    keyIterator.remove();
}

split 方法

private static void split(ByteBuffer source) {
    source.flip();
    for (int i = 0; i < source.limit(); i++) {
        // 找到一条完整消息
        if (source.get(i) == '\n') {
            int length = i + 1 - source.position();
            // 把这条完整消息存入新的 ByteBuffer
            ByteBuffer target = ByteBuffer.allocate(length);
            // 从 source 读,向 target 写
            for (int j = 0; j < length; j++) {
                target.put(source.get());
            }
            debugAll(target);
        }
    }
    source.compact(); // 0123456789abcdef  position 16 limit 16
}

处理 write 事件

一次无法写完的例子
  • 非阻塞模式下,无法保证把 buffer 中所有数据都写入 channel,因此需要追踪 write 方法的返回值(代表实际写入的字节数)
  • 用 selector 监听所有 channel 的可写事件,每个 channel 都需要一个 key 来跟踪 buffer,但这样又会导致占用内存过多,就有两阶段策略

    • 当消息处理器第一次写入消息时,才将 channel 注册到 selector 上
    • selector 检查 channel 上的可写事件,如果所有的数据写完了,就取消 channel 的注册
    • 如果不取消,会每次可写均会触发 write 事件
public class WriteServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress(8080));

        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while(true) {
            selector.select();

            Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();
                if (key.isAcceptable()) {
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ);
                    // 1. 向客户端发送内容
                    StringBuilder sb = new StringBuilder();
                    for (int i = 0; i < 3000000; i++) {
                        sb.append("a");
                    }
                    ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
                    int write = sc.write(buffer);
                    // 3. write 表示实际写了多少字节
                    System.out.println("实际写入字节:" + write);
                    // 4. 如果有剩余未读字节,才需要关注写事件
                    if (buffer.hasRemaining()) {
                        // read 1  write 4
                        // 在原有关注事件的基础上,多关注 写事件
                        //key.interestOps() 表示原有关注的时间,+  SelectionKey.OP_WRITE 写事件
                        sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE);
                        // 把 buffer 作为附件加入 sckey
                        sckey.attach(buffer);
                    }
                } else if (key.isWritable()) {
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    SocketChannel sc = (SocketChannel) key.channel();
                    int write = sc.write(buffer);
                    System.out.println("实际写入字节:" + write);
                    if (!buffer.hasRemaining()) { // 写完了
                        // 为什么要取消关注 写事件
                        // 只要向 channel 发送数据时,socket 缓冲可写,这个事件会频繁触发,因此应当只在 socket 缓冲区写不下时再关注可写事件,数据写完之后应该取消关注
                        key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);
                        key.attach(null);
                    }
                }
            }
        }
    }
}

客户端

public class WriteClient {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        SocketChannel sc = SocketChannel.open();
        sc.configureBlocking(false);
        sc.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
        sc.connect(new InetSocketAddress("localhost", 8080));
        int count = 0;
        while (true) {
            selector.select();
            Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();
                if (key.isConnectable()) {
                    System.out.println(sc.finishConnect());
                } else if (key.isReadable()) {
                    ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
                    count += sc.read(buffer);
                    buffer.clear();
                    System.out.println(count);
                }
            }
        }
    }
}

文件编程 FileChannel

FileChannel 只能工作在阻塞模式下,没有非阻塞模式

获取FileChannel 时,不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法

  • 通过 FileInputStream 获取的 channel 只能读
  • 通过 FileOutputStream 获取的 channel 只能写
  • 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定

两个 Channel 传输数据

String FROM = "helloword/data.txt";
String TO = "helloword/to.txt";
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
     FileChannel to = new FileOutputStream(TO).getChannel();
    ) {
    from.transferTo(0, from.size(), to);
} catch (IOException e) {
    e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("transferTo 用时:" + (end - start) / 1000_000.0);//transferTo 用时:8.2011

超过 2g 大小的文件传输

public class TestFileChannelTransferTo {
    public static void main(String[] args) {
        try (
                FileChannel from = new FileInputStream("data.txt").getChannel();
                FileChannel to = new FileOutputStream("to.txt").getChannel();
        ) {
            // 效率高,底层会利用操作系统的零拷贝进行优化
            long size = from.size();
            // left 变量代表还剩余多少字节
            for (long left = size; left > 0; ) {
                System.out.println("position:" + (size - left) + " left:" + left);
                left -= from.transferTo((size - left), left, to);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

实际传输一个超大文件

position:0 left:7769948160
position:2147483647 left:5622464513
position:4294967294 left:3474980866
position:6442450941 left:1327497219

FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。它解决数据从磁盘读取到内核缓冲区,然后内核缓冲区的数据复制移动到用户空间缓冲区。程序还是需要从用户态切换到内核态,然后再进行操作系统调用,并且数据移动和复制了两次。

transferTo方法则是使用了sendfile的方式,来分析一下其中原理:

  • transferTo()方法直接将当前通道内容传输到另一个通道,没有涉及到Buffer的任何操作,NIO中的Buffer是JVM堆或者堆外内存,但不论如何他们都是操作系统内核空间的内存。也就是说这种方式不会有内核缓冲区和用户缓冲区之间的拷贝问题。
  • transferTo()的实现方式就是通过系统调用sendfile()(当然这是Linux中的系统调用),根据我们上面所写说这个过程是效率远高于从内核缓冲区到用户缓冲区的读写的。
  • 同理transferFrom()也是这种实现方式。

具体细节可以看这篇文章 网络编程 - NIO的零拷贝实现

网络编程

JAVA NIO 框架简要设计分析

多路复用IO技术是操作系统的内核实现。在不同的操作系统,甚至同一系列操作系统的版本中所实现的多路复用IO技术都是不一样的。那么作为跨平台的JAVA JVM来说如何适应多种多样的多路复用IO技术实现呢? 面向对象的威力就显现出来了: 无论使用哪种实现方式,他们都会有“选择器”、“通道”、“缓存”这几个操作要素,那么可以为不同的多路复用IO技术创建一个统一的抽象组,并且为不同的操作系统进行具体的实现。JAVA NIO中对各种多路复用IO的支持,主要的基础是java.nio.channels.spi.SelectorProvider抽象类,其中的几个主要抽象方法包括:

  • public abstract DatagramChannel openDatagramChannel(): 创建和这个操作系统匹配的UDP 通道实现。
  • public abstract AbstractSelector openSelector(): 创建和这个操作系统匹配的NIO选择器,就像上文所述,不同的操作系统,不同的版本所默认支持的NIO模型是不一样的。
  • public abstract ServerSocketChannel openServerSocketChannel(): 创建和这个NIO模型匹配的服务器端通道。
  • public abstract SocketChannel openSocketChannel(): 创建和这个NIO模型匹配的TCP Socket套接字通道(用来反映客户端的TCP连接)

由于JAVA NIO框架的整个设计是很大的,所以我们只能还原一部分我们关心的问题。这里我们以JAVA NIO框架中对于不同多路复用IO技术的选择器 进行实例化创建的方式作为例子,以点窥豹观全局:

很明显,不同的SelectorProvider实现对应了不同的 选择器。由具体的SelectorProvider实现进行创建。另外说明一下,实际上netty底层也是通过这个设计获得具体使用的NIO模型。以下代码是Netty 4.0中NioServerSocketChannel进行实例化时的核心代码片段:

private static ServerSocketChannel newSocket(SelectorProvider provider) {
    try {
        /**
            *  Use the {@link SelectorProvider} to open {@link SocketChannel} and so remove condition in
            *  {@link SelectorProvider#provider()} which is called by each ServerSocketChannel.open() otherwise.
            *
            *  See <a href="See https://github.com/netty/netty/issues/2308">#2308</a>.
            */
        return provider.openServerSocketChannel();
    } catch (IOException e) {
        throw new ChannelException(
                "Failed to open a server socket.", e);
    }
}

JAVA实例 - 利用多线程优化

前面的代码只有一个选择器,没有充分利用多核 cpu。而现在都是多核 cpu,设计时要充分考虑别让 cpu 的力量被白白浪费

分两组选择器

  • 单线程配一个选择器,专门处理 accept 事件
  • 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read 事件

public class ChannelDemo7 {
    public static void main(String[] args) throws IOException {
        new BossEventLoop().register();
    }


    @Slf4j
    static class BossEventLoop implements Runnable {
        private Selector boss;//只负责建立连接
        private WorkerEventLoop[] workers;//负责处理业务能力
        private volatile boolean start = false;
        AtomicInteger index = new AtomicInteger();

        public void register() throws IOException {
            if (!start) {
                ServerSocketChannel ssc = ServerSocketChannel.open();
                ssc.bind(new InetSocketAddress(8080));
                ssc.configureBlocking(false);
                //获取 boss 的选择器
                boss = Selector.open();
                //将ssc 绑定到boss的选择器
                SelectionKey ssckey = ssc.register(boss, 0, null);
                ssckey.interestOps(SelectionKey.OP_ACCEPT);
                workers = initEventLoops();
                //启动boss线程,接收accept事件
                new Thread(this, "boss").start();
                log.debug("boss start...");
                start = true;
            }
        }

        public WorkerEventLoop[] initEventLoops() {
         //Runtime.getRuntime().availableProcessors(可以拿到 cpu 个数
         //但是如果工作在 docker 容器下,因为容器不是物理隔离的,会拿到物理 cpu 个数,而不是容器申请时的个数
        // 这个问题直到 jdk 10 才修复,使用 jvm 参数 UseContainerSupport 配置, 默认开启
//        EventLoop[] eventLoops = new EventLoop[Runtime.getRuntime().availableProcessors()];
            //创建处理业务的线程
            WorkerEventLoop[] workerEventLoops = new WorkerEventLoop[2];
            for (int i = 0; i < workerEventLoops.length; i++) {
                workerEventLoops[i] = new WorkerEventLoop(i);
            }
            return workerEventLoops;
        }

        @Override
        public void run() {
            while (true) {
                try {
                    boss.select();
                    Iterator<SelectionKey> iter = boss.selectedKeys().iterator();
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove();
                        if (key.isAcceptable()) {
                            ServerSocketChannel c = (ServerSocketChannel) key.channel();
                            SocketChannel sc = c.accept();
                            sc.configureBlocking(false);
                            log.debug("{} connected", sc.getRemoteAddress());
                            //选择哪个线程来注册这个 accept事件
                            workers[index.getAndIncrement() % workers.length].register(sc);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    @Slf4j
    static class WorkerEventLoop implements Runnable {
        private Selector worker;
        private volatile boolean start = false;
        private int index;

        private final ConcurrentLinkedQueue<Runnable> tasks = new ConcurrentLinkedQueue<>();

        public WorkerEventLoop(int index) {
            this.index = index;
        }

        public void register(SocketChannel sc) throws IOException {
            if (!start) {
                worker = Selector.open();
                new Thread(this, "worker-" + index).start();
                start = true;
            }
            tasks.add(() -> {
                try {
                    SelectionKey sckey = sc.register(worker, 0, null);
                    //关注读事件
                    sckey.interestOps(SelectionKey.OP_READ);
                    worker.selectNow();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
            worker.wakeup();
        }

        @Override
        public void run() {
            while (true) {
                try {
                    worker.select();
                    Runnable task = tasks.poll();
                    if (task != null) {
                        task.run();
                    }
                    Set<SelectionKey> keys = worker.selectedKeys();
                    Iterator<SelectionKey> iter = keys.iterator();
                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        //只需要关注 读事件
                        if (key.isReadable()) {
                            SocketChannel sc = (SocketChannel) key.channel();
                            ByteBuffer buffer = ByteBuffer.allocate(128);
                            try {
                                // 这里没再关注 粘包半包 的问题了
                                int read = sc.read(buffer);
                                if (read == -1) {
                                    key.cancel();
                                    sc.close();
                                } else {
                                    buffer.flip();
                                    log.debug("{} message:", sc.getRemoteAddress());
                                    //... 处理业务
                                }
                            } catch (IOException e) {
                                e.printStackTrace();
                                key.cancel();
                                sc.close();
                            }
                        }
                        iter.remove();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

UDP

  • UDP 是无连接的,client 发送数据不会管 server 是否开启
  • server 这边的 receive 方法会将接收到的数据存入 byte buffer,但如果数据报文超过 buffer 大小,多出来的数据会被默默抛弃

首先启动服务器端

public class UdpServer {
    public static void main(String[] args) {
        try (DatagramChannel channel = DatagramChannel.open()) {
            channel.socket().bind(new InetSocketAddress(9999));
            System.out.println("waiting...");
            ByteBuffer buffer = ByteBuffer.allocate(32);
            channel.receive(buffer);
            buffer.flip();
            //业务处理
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

运行客户端

public class UdpClient {
    public static void main(String[] args) {
        try (DatagramChannel channel = DatagramChannel.open()) {
            ByteBuffer buffer = StandardCharsets.UTF_8.encode("hello");
            InetSocketAddress address = new InetSocketAddress("localhost", 9999);
            channel.send(buffer, address);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

多路复用IO的优缺点

  • 不用再使用多线程来进行IO处理了(包括操作系统内核IO管理模块和应用程序进程而言)。当然实际业务的处理中,应用程序进程还是可以引入线程池技术的
  • 同一个端口可以处理多种协议,例如,使用ServerSocketChannel测测的服务器端口监听,既可以处理TCP协议又可以处理UDP协议。
  • 操作系统级别的优化: 多路复用IO技术可以是操作系统级别在一个端口上能够同时接受多个客户端的IO事件。同时具有之前我们讲到的阻塞式同步IO和非阻塞式同步IO的所有特点。Selector的一部分作用更相当于“轮询代理器”。
  • 都是同步IO: 目前介绍的 阻塞式IO、非阻塞式IO甚至包括多路复用IO,这些都是基于操作系统级别对“同步IO”的实现。我们一直在说“同步IO”,一直都没有详细说,什么叫做“同步IO”。实际上一句话就可以说清楚: 只有上层(包括上层的某种代理机制)系统询问我是否有某个事件发生了,否则我不会主动告诉上层系统事件发生了

存在的误区

最初在认识上有这样的误区,认为只有在 netty,nio 这样的多路复用 IO 模型时,读写才不会相互阻塞,才可以实现高效的双向通信,但实际上,Java Socket 是全双工的:在任意时刻,线路上存在A 到 BB 到 A 的双向信号传输。即使是阻塞 IO,读和写是可以同时进行的,只要分别采用读线程和写线程即可,读不会阻塞写、写也不会阻塞读

服务端:

public class TestServer {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(8888);
        Socket s = ss.accept();

        new Thread(() -> {
            try {
                BufferedReader reader = new BufferedReader(new InputStreamReader(s.getInputStream()));
                while (true) {
                    System.out.println(reader.readLine());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(s.getOutputStream()));
                // 例如在这个位置加入 thread 级别断点,可以发现即使不写入数据,也不妨碍前面线程读取客户端数据
                for (int i = 0; i < 100; i++) {
                    writer.write(String.valueOf(i));
                    writer.newLine();
                    writer.flush();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

客户端:

public class TestClient {
    public static void main(String[] args) throws IOException {
        Socket s = new Socket("localhost", 8888);

        new Thread(() -> {
            try {
                BufferedReader reader = new BufferedReader(new InputStreamReader(s.getInputStream()));
                while (true) {
                    System.out.println(reader.readLine());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(s.getOutputStream()));
                for (int i = 0; i < 100; i++) {
                    writer.write(String.valueOf(i));
                    writer.newLine();
                    writer.flush();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

JavaNIO的缺陷

使用 Java 原生 NIO 来编写服务器应用,代码一般类似:

// 创建、配置 ServerSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(9998));
serverChannel.configureBlocking(false);
 
// 创建 Selector
Selector selector = Selector.open();
 
// 注册
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
 
while (true) {
    selector.select();  // select 可能在无就绪事件时异常返回!
 
    Set<SelectionKey> readyKeys = selector.selectedKeys();
    Iterator<SelectionKey> it = readyKeys.iterator();
 
    while (it.hasNext()) {
        SelectionKey key = it.next();
        ...  // 处理事件
        it.remove();
    }
}

selector.select() 应该 一直阻塞,直到有就绪事件到达,但很遗憾,由于 Java NIO 实现上存在 bug,select() 可能在 没有 任何就绪事件的情况下返回,从而导致 while(true) 被不断执行,最后导致某个 CPU 核心的利用率飙升到 100%,这就是臭名昭著的 Java NIO 的 epoll bug。

实际上,这是 Linux 系统下 poll/epoll 实现导致的 bug,但 Java NIO 并未完善处理它,所以也可以说是 Java NIO 的 bug。
该问题最早在 Java 6 发现,随后很多版本声称解决了该问题,但实际上只是降低了该 bug 的出现频率,起码从网上搜索看,Java 8 还是存在该问题。

免费!免费!通通免费,全速开蹬!




全场消费 0 倍率,Claude 统统免费,Opus 4.6 免费使用!

上下游全链路,您的对话数据不存储,隐私!隐私!他喵的还是隐私!

👉 注册地址 https://bestaigate.top/register

📖 注册后回帖 不墨迹!

🔥 前 [ 50 ] 名直接送 100 刀!手慢只有 50 刀!

回帖记得带上你的 id 哟!如图 👇

⚠️回帖抽马年纪念一钞一币礼盒,共计 5 套

⚠️大笑脸开户 · 专业低佣券商开户
⚠️开户送五常大米,推荐朋友有红包

⚠️大笑脸低佣开户推荐:
银河证券:ETF 万 0.5 免 5 [ETF,LOF ,etf 免五费率最低,适合 etf ,打新,纳指,宽基玩家。新手必备]
光大证券: 万 0.854 免 5 [头部龙头券商,股票 etf 费率都很低]
开源证券: 万 0.854 免 5 [国企券商,股票 etf 费率都很低]
国泰海通: 万 0.8 免 5 [头部龙头券商,费率最优惠,大客户专享,综合费率最低]
平安证券:50 万两融低至 3.58%

⚠️大笑脸联系方式和最新具体券商表格清单,随时更新 www.daxiaolian.com

MetaForm 低代码引擎系列 · 第 2 篇
技术栈:Vue.js 3 + Composition API + 动态组件

一、前端硬编码的终结

在传统前端开发中,表单页面是这样写的:

<!-- 硬编码的噩梦 -->
<el-form>
  <el-input v-model="form.name" placeholder="姓名" />
  <el-select v-model="form.gender">
    <el-option label="男" value="male" />
    <el-option label="女" value="female" />
  </el-select>
  <el-date-picker v-model="form.birthday" />
</el-form>

每个 <input>、每个 <select> 都硬编码在 .vue 文件中。这种做法在低代码系统中无法存活

  1. 结构不可预知:表单由租户管理员在运行时动态创建,前端不可能在编译期知道会存在哪些字段。
  2. 变更成本极高:一个 Placeholder 的变更都要走 拉分支 → 修改代码 → 构建 → 部署 的漫长流程。

要解决这个问题,前端必须与业务逻辑完全解耦:前端只提供原子化的组件和布局容器,页面的拓扑形态完全由后端下发的一份 JSON 元数据(Schema)动态决定。

这种架构在 Salesforce 中被称为 FlexiPageLayout 体系。


二、定义 Layout Schema 协议

在动手写前端代码之前,我们需要与后端确立一套接口规范。前端发起请求:

GET /api/layout/{form_id}

后端返回如下结构:

{
  "layout_id": "lay_1001",
  "title": "入职申请表",
  "action_url": "/api/data/frm_1001",
  "sections": [
    {
      "section_id": "sec_basic",
      "section_title": "基础信息",
      "columns": [
        {
          "items": [
            {
              "field_name": "employee_name",
              "field_type": "string",
              "component_type": "MetaInput",
              "label": "姓名",
              "required": true,
              "max_length": 50,
              "placeholder": "请输入真实姓名"
            }
          ]
        },
        {
          "items": [
            {
              "field_name": "gender",
              "field_type": "string",
              "component_type": "MetaSelect",
              "label": "性别",
              "required": false,
              "options": ["男", "女"]
            }
          ]
        }
      ]
    },
    {
      "section_id": "sec_detail",
      "section_title": "详细信息",
      "columns": [
        {
          "items": [
            {
              "field_name": "join_date",
              "field_type": "date",
              "component_type": "MetaDate",
              "label": "入职日期"
            }
          ]
        }
      ]
    }
  ]
}

注意那个关键的 component_type 字段——它将指导 Vue 引擎进行组件的动态装载。


三、Vue 3 动态渲染引擎实现

3.1 原子组件封装

首先,将底层 UI 库的组件封装为符合 Meta 规范的原子组件:

<!-- MetaInput.vue -->
<template>
  <!-- 动态读取 schema 中的 rules,转化为底层 UI 库的属性 -->
  <el-form-item :label="schema.label" :required="schema.required">
    <el-input
      v-model="internalValue"
      :placeholder="schema.placeholder"
      :maxlength="schema.max_length"
      :show-word-limit="!!schema.max_length"
    />
  </el-form-item>
</template>

<script setup>
import { computed } from "vue";

const props = defineProps({
  schema: Object,
  modelValue: [String, Number],
});
const emit = defineEmits(["update:modelValue"]);

const internalValue = computed({
  get: () => props.modelValue,
  set: (val) => emit("update:modelValue", val),
});
</script>

同理封装 MetaSelect.vueMetaDate.vue 等原子组件。

架构师提示(关于动态校验规则下发):底层的原子组件不仅负责渲染 UI,更需要忠实地继承 Layout Schema 中定义的业务规则。如上面的 MetaInput,通过直接读取 JSON 中的 requiredmax_length 属性,并绑定到 <el-input> 上,前端无需硬编码任何繁复的校验逻辑,便自然拥有了浏览器和 UI 库提供的表单拦截能力。

3.2 动态 Component Factory 解析器

核心魔法是 Vue 的内置 <component> 指令,结合 is 属性:

<!-- DynamicFormParser.vue -->
<template>
  <div class="dynamic-form" v-if="layoutSchema">
    <h2>{{ layoutSchema.title }}</h2>

    <div
      v-for="section in layoutSchema.sections"
      :key="section.section_id"
      class="form-section"
    >
      <h3>{{ section.section_title }}</h3>

      <!-- 细化层级结构:遍历列 (Column) -->
      <div class="section-layout" style="display: flex; gap: 24px;">
        <div
          v-for="(col, colIdx) in section.columns"
          :key="colIdx"
          class="layout-column"
          style="flex: 1;"
        >
          <!-- 引擎核心:<component :is> 动态加载该列中的 items -->
          <component
            v-for="item in col.items"
            :key="item.field_name"
            :is="getComponent(item.component_type)"
            :schema="item"
            v-model="formData[item.field_name]"
          />
        </div>
      </div>
    </div>

    <el-button type="primary" @click="submitData">提交表单</el-button>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import axios from "axios";
import MetaInput from "./components/MetaInput.vue";
import MetaSelect from "./components/MetaSelect.vue";
import MetaDate from "./components/MetaDate.vue";

const props = defineProps({ formId: String });

const layoutSchema = ref(null);
const formData = ref({}); // 核心:所有动态组件的数据归宿

const componentMap = { MetaInput, MetaSelect, MetaDate };
const getComponent = (typeStr) => componentMap[typeStr] || MetaInput;

onMounted(async () => {
  const { data } = await axios.get(`/api/layout/${props.formId}`);
  layoutSchema.value = data;
});

const submitData = async () => {
  // formData.value 就是完美的 JSON Payload
  await axios.post(layoutSchema.value.action_url, formData.value);
};
</script>

<style scoped>
.grid {
  display: grid;
  gap: 16px;
}
.form-section {
  margin-bottom: 24px;
}
</style>

四、双向绑定的精髓

这套代码中最精妙的一笔是 v-model="formData[item.field_name]"

formData 是一个初始化为空的 ref({}) 对象。当 Vue 渲染包含 field_name: "employee_name" 的组件时,它会自动在 formData 中创建键值 formData.employee_name,并通过 update:modelValue 事件实现双向绑定。

我们完全不用操心有多少个字段、什么嵌套结构。点击"提交"时,formData.value 里就是一份完美的、准备好塞给后端 JSONB payload 的数据体。


五、Schema 驱动渲染管线图解

Schema 驱动渲染管线

整个流程分为 4 步:

  1. API Response:后端返回 Layout JSON 描述(字段类型、名称、校验规则等)
  2. Vue Component Factory<component :is="..."> 解析器读取 JSON 并进行路由分发
  3. UI Components:分发出具体的原子组件实体(MetaInput、MetaNumber、MetaDate)
  4. State Collection:所有组件的值通过 v-model 双向绑定到集中的 JSON Payload State

小结

  • 前端零硬编码:所有表单结构由后端元数据驱动,无需手写模板
  • 可无限扩展:新增字段只需在元数据层配置,前端自动渲染对应组件
  • 数据自动收集formData 统一收集所有组件的值,与后端 DML 引擎无缝对接
下一篇预告:前端收集好了 formData,这份 JSON Payload 如何安全地经过类型转换、规范化编码后落入 PostgreSQL 的 JSONB 堆表?请看第 3 篇《运行时数据引擎 —— DML 拦截与 JSONB 检索》。

MetaForm 低代码引擎系列 · 第 3 篇 基于 JSONB 函数索引的高性能 DML 落地指南
技术栈:Python FastAPI + PostgreSQL JSONB + GIN 索引

一、规范化格式 (Canonical Format) 的必要性

上一篇中,Vue 前端收集到了一份 JSON Payload

{
  "employee_name": "张三",
  "age": "25",
  "join_date": "2024年1月1日"
}

如果直接把这段未经处理的 JSON 塞入 data_heap.payload,会埋下两颗定时炸弹:

  1. 类型崩坏:前端传来的 age: "25" 是字符串,字符串的 "100" 在字典序中排在 "25" 前面(因为首字符 1 < 9)。数值比较将完全错误。
  2. 格式灾难:中文日期格式无法使用数据库的原生时间窗口过滤功能。

由于我们放弃了关系型数据库原生的强类型列(int, timestamp),就必须在应用层用代码将类型安全补回来。这个过程叫做数据规范化 (Normalization) 与强制转换 (Type Cast)

数据入库拦截器

数据入库拦截器 - Data Cast Pipeline

前端的 Raw JSON 进入运行时引擎后,引擎从 UDD 缓存中拉取 meta_fields 进行对比,将字符型的 "18" 转换为整型 18,日期格式化为 ISO 8601,最终以 Formatted JSONB 落入 data_heap 物理表。


二、通用 DML 写入接口实现

我们设计 POST /api/data/{form_id} 作为所有数据的唯一入口:

from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from datetime import datetime
import json, uuid

router = APIRouter(prefix="/api/data")

def canonical_encode(value, field_type: str):
    """根据元数据类型,强制格式化数据"""
    if value is None:
        return None
    if field_type == "number":
        return float(value)
    elif field_type == "date":
        # 支持多种输入格式,统一输出 ISO 8601
        if isinstance(value, str):
            for fmt in ["%Y-%m-%d", "%Y年%m月%d日", "%Y/%m/%d"]:
                try:
                    return datetime.strptime(value, fmt).isoformat()
                except ValueError:
                    continue
        return str(value)
    elif field_type == "boolean":
        return bool(value)
    else:
        return str(value)


@router.post("/{form_id}")
def insert_record(form_id: str, payload: dict, db: Session = Depends(get_db)):
    # 1. 加载元数据蓝图(通常走 Redis/LRU Cache)
    fields_meta = db.execute(
        "SELECT field_name, field_type, is_required FROM meta_fields WHERE form_id = :fid",
        {"fid": form_id}
    ).fetchall()

    # 2. 运行时类型转换与校验
    canonical_payload = {}
    for meta in fields_meta:
        raw_val = payload.get(meta.field_name)

        # 必填校验拦截
        if meta.is_required and raw_val is None:
            raise HTTPException(422, f"字段 '{meta.field_name}' 为必填项")

        if raw_val is not None:
            canonical_payload[meta.field_name] = canonical_encode(raw_val, meta.field_type)

    # 3. 组装 JSONB 并写入堆表
    record_id = str(uuid.uuid4())
    db.execute(
        """INSERT INTO data_heap (id, org_id, form_id, payload)
           VALUES (:id, :org_id, :form_id, :payload::jsonb)""",
        {
            "id": record_id,
            "org_id": "tenant-001",
            "form_id": form_id,
            "payload": json.dumps(canonical_payload, ensure_ascii=False)
        }
    )
    db.commit()
    return {"status": "ok", "id": record_id}

整个流程:加载元数据 → 类型转换 → 组装 JSONB → SQL Insert,一气呵成。


三、动态 SQL 与 JSONB 查询

数据落地后,更具挑战的是取出来。

假设前端请求:"查询年龄大于 18 岁的记录":

GET /api/data/frm_1001?age__gt=18

运行时引擎充当查询翻译器 (Query Builder)

  1. 解析字段名与操作符:提取字段 age,操作符 __gt (Greater Than)
  2. 结合元数据判定类型:查 meta_fields 缓存得知 ageNumber 类型
  3. 编译底层 PGSQL:使用 JSONB 深度提取操作符 ->>,附加显式类型转换 ::numeric

JSONB 查询翻译器

JSONB 查询翻译器

前端的 API 请求被 Query Builder 引擎结合元数据翻译为底层 SQL,关键在于 ->> 操作符和 ::numeric 类型强转:

SELECT id, payload
FROM data_heap
WHERE form_id = 'frm_1001'
  AND (payload->>'age')::numeric > 18;
@router.get("/{form_id}")
def query_records(form_id: str, request: Request, db: Session = Depends(get_db)):
    # 解析查询参数如 age__gt=18, name__contains=张
    filters = []
    params = {"fid": form_id}

    for key, value in request.query_params.items():
        if "__" in key:
            field_name, operator = key.rsplit("__", 1)
            # 从元数据获取字段类型
            field_meta = get_field_type(db, form_id, field_name)

            if operator == "gt":
                cast = "::numeric" if field_meta == "number" else ""
                filters.append(f"(payload->>'{field_name}'){cast} > :val_{field_name}")
                params[f"val_{field_name}"] = value
            elif operator == "eq":
                filters.append(f"payload->>'{field_name}' = :val_{field_name}")
                params[f"val_{field_name}"] = value

    where_clause = " AND ".join(filters) if filters else "1=1"
    results = db.execute(
        f"SELECT id, payload FROM data_heap WHERE form_id = :fid AND {where_clause}",
        params
    ).fetchall()

    return [{"id": r.id, "data": r.payload} for r in results]

四、性能进阶:索引策略

4.1 GIN 倒排索引

JSONB 之所以能在生产环境立足,是因为 GIN (Generalized Inverted Index) 倒排索引

-- 为整个 JSONB payload 建立 GIN 索引
CREATE INDEX idx_heap_payload ON data_heap USING GIN (payload jsonb_path_ops);

PostgreSQL 会将 JSONB 文档内所有键值组合索引化。使用 @> (包含) 操作符时,数据库直接走倒排索引,速度与普通 B-Tree 索引差距极小。

-- 精确匹配查询(走 GIN 索引)
SELECT * FROM data_heap
WHERE payload @> '{"status": "active"}'::jsonb;

4.2 性能核弹:函数/表达式索引 (Functional Indexes)

架构师提示(解决全表扫描的终极杀器):在我们上面的查询翻译器中,出现了 (payload->>'age')::numeric > 18 这样的条件。
必须警惕:在千万级数据量下,每次查询都做显式的 ::numeric 类型转换是无法命中普通 GIN 索引的,这必定会导致极其缓慢的全表扫描。

为了彻底解决 Schema-Free 宽表范围检索的性能瓶颈,Salesforce 在其底层架构中设计了复杂的 MT_Indexes(索引透视表)。而在现代 PostgreSQL 中,我们拥有更优雅的原生武器:函数索引(表达式索引)

对于那些在业务上被高频用于范围检索(大于、小于、区间等)的数字或日期字段,系统可以在后台自动静默建立一个强类型的表达式索引:

-- 为高频检索的数值字段 age 创建"提纯"的表达式 B-Tree 索引
CREATE INDEX idx_heap_age_numeric
ON data_heap USING BTREE (((payload->>'age')::numeric));

一旦建立了这个索引,PGSQL 的查询优化器(Planner)会非常聪明地在执行 (payload->>'age')::numeric > 18 查询时,自动感知并直接命中这个原生的强类型 B-Tree 索引,完全跳过全表扫描。这让 JSONB 的范围过滤拥有了与原生独立物理数据库列极度接近的极限查询性能!


小结

  • 拦截 + 编码:运行时引擎确保所有业务数据在写入前完成类型安全转换
  • JSONB + GIN:提供灵活的 Schema-Free 存储,同时保持高效查询能力
  • 统一入口:前端只需提交普通 JSON,后端负责全部转换与持久化
下一篇预告:数据能正确落库了,但如何在写入前自动执行业务校验规则(如"分数不能为负")?

MetaForm 低代码引擎系列 · 第 1 篇
技术栈:PostgreSQL (JSONB) + Python FastAPI + Vue.js

一、痛点引入:无限建表的 DDL 灾难

在传统的软件开发模式中,我们的潜意识里有一条不可动摇的黄金定律:一个业务对象,就必然对应数据库里的一张物理表。

比如我们要开发一个问卷系统,很自然地会建立 Survey(问卷表)、Question(题目表)、Response(答卷表)。表里定义好具体的列:titleVARCHARscoreINTcreated_atTIMESTAMP。各司其职,结构清晰。

但是,现代企业级 SaaS(比如低代码平台、灵活的 CRM 系统)面临的核心挑战是:极端的个性化诉求规模化。

设想一下,你的平台服务了成百上千个企业客户(租户):

  • A 企业希望在问卷里加一个"所属行业"字段;
  • B 企业希望加一个"紧急程度"字段;
  • C 企业甚至想完全新建一个叫"问卷回访跟进"的新业务模块。

如果坚持"一对象一表"的传统架构,灾难接踵而至:

  1. DDL 风暴 (Data Definition Language Storm):上百个租户各自在界面上点击"添加字段"时,后台就要向数据库发送大量 ALTER TABLE ADD COLUMN 语句。DDL 操作会锁表(Metadata Lock),在高并发的生产数据库中等同于自杀。
  2. 运维的无底洞:如果为每个租户单独建表,1 万租户 × 50 张表 = 50 万张表。数据字典极度膨胀,备份、升级、统一修改都变得困难。
  3. 隔离性极其脆弱:多租户环境下 Schema 完全不同,一套代码体系很难处理所有边缘情况,最后陷入 if-else 的泥潭。

面对这些困难,业界诞生了一个近乎"离经叛道"的核心选择:彻底放弃让应用层直接操作数据库 Schema。

数据库退化为纯粹的数据仓库,它不关心也不知道具体的业务模型长什么样。至于"系统里有哪些表、表里有哪些字段"这种工作,被"上架"到了应用层来管理。

这就是元数据驱动架构(Metadata-Driven Architecture)的起点。


二、核心理念:通用数据字典 (UDD)

元数据 (Metadata),简单来说就是"描述数据的数据"

如果说普通的业务数据记录的是"张三考了 95 分",那么元数据记录的就是"系统里有一个叫『问卷』的表,它有一列叫『分数』"。

管理这些元数据的系统,我们称之为 通用数据字典 (Universal Data Dictionary, UDD)

核心思想是:既然底层数据库不让自由建表了,那就拿两张固定的表当"户口本",把用户想要的表结构"登记"在册。

meta_forms(登记"有什么表")

当你在低代码后台点击"新建表单"并命名为"问卷调查"时,底层不会执行 CREATE TABLE。系统只是在 meta_forms 中插入一行记录。

meta_fields(登记"表里有什么列")

每当你在页面上拖拽生成一个"问卷标题"的输入框,系统就在 meta_fields 表里加上一行。

理解关键点:在这个体系里,修改系统结构不再是高危的数据库操作(DDL),而变成了最简单的增删改查(CRUD)。

三、架构对比图解

传统架构 vs 元数据架构对比

传统架构中,每个业务对象(User、Order、Survey)都有自己独立的物理表,每次结构变更都需要 ALTER TABLE。而在 MetaForm 的元数据架构中,只有两张配置表 meta_formsmeta_fields 负责定义结构,所有业务数据统一落入 data_heap 的 JSONB payload 字段中。


四、技术抉择:为什么是 PGSQL + JSONB

结构定义好了,实际的业务数据存哪里?

Salesforce 早期使用了 弹性宽表 (Flex Table) 方案:建一张超级大的宽表,包含 Value0Value500 共 500 个 VARCHAR(255) 列。元数据字典记录某个字段存入了哪个具体的 Slot(例如"手机号"存入 Value3)。

虽然实现了 Schema-Free,但痛点极多:

  • 所有数据都被强制转为字符串,数值排序、日期过滤极度痛苦
  • 空槽位造成巨大的存储浪费
  • 需要维护复杂的 Slot Mapping 逻辑

在现代技术栈下,我们选择 PostgreSQL + JSONB 作为底层底座。这是一种降维打击:

CREATE TABLE data_heap (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id VARCHAR(64) NOT NULL,    -- 租户隔离
    form_id VARCHAR(64) NOT NULL,   -- 关联 meta_forms(⚠️ 绝对的隔离条件)
    payload JSONB NOT NULL,         -- 核心:所有业务数据打包在此
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- GIN 索引:加速 JSONB 内部的键值检索
CREATE INDEX idx_data_heap_payload ON data_heap USING GIN (payload jsonb_path_ops);
架构师提示(关于数据隔离的底线):由于所有的表单数据都在 data_heap 这个“大通铺”里,哪怕在单租户架构下,查询时 WHERE form_id = 'xxx' 也是绝对不可或缺的隔离条件。这就如同给数据分区,必须在后端底层DAO层强行统一带上 form_id,防止业务数据产生灾难级的越界。

### JSONB 的核心优势与 Key 映射规则

1. **天然 Schema-Free**:无论前端提交多少动态字段,直接打包成 JSON 塞进 `payload` 字段,彻底免除了维护 Slot Mapping 的痛苦。
   * ** 架构提示(关于 Key 的映射)**:在设计 `meta_fields` 时,强烈建议区分**显示名 (Label)** 和 **内部标识名 (DeveloperName / API Name, 如 `age__c`)**。JSONB 内部的 Key **必须**使用不可变的 `field_api_name`,而不是可能会被业务人员随时修改的中文显示名,以此保证底层物理数据的稳定性。
2. **极速数据定位**:通过 PGSQL 的 JSONB 操作符(如 `->>`、`#>>`),可以轻松查询深层结构:
   ```sql
   SELECT payload->>'phone_number' FROM data_heap WHERE form_id = 'frm_1001';
  1. GIN 索引加速:只需为 payload 建立一个 GIN 倒排索引,就能自动加速所有基于 JSON Key/Value 的检索,碾压传统的逐列 B-Tree 索引。

JSONB 存储映射透视

JSONB 存储映射透视图

上方 meta_fields 定义了字段名(如 AgeName)和类型,下方 data_heappayload 列中,这些字段名直接作为 JSON 的 Key 存储。元数据定义了 JSON 内部的结构。


五、 API 设计

了解了物理底座后,来看后端的 API 接口。整个平台只需要几个元数据管理接口:

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List
import uuid

router = APIRouter(prefix="/api/meta")

class FieldCreate(BaseModel):
    field_name: str
    field_type: str  # 'string', 'number', 'boolean', 'date'
    is_required: bool = False

class FormCreate(BaseModel):
    name: str
    description: str = ""
    fields: List[FieldCreate]

@router.post("/forms")
def create_dynamic_form(body: FormCreate, db: Session = Depends(get_db)):
    """
    新建动态表单:操作元数据字典,而非 DDL 建表。
    """
    form_id = f"frm_{uuid.uuid4().hex[:8]}"

    # 1. 在 meta_forms 中登记"虚拟表"
    db.execute(
        "INSERT INTO meta_forms (form_id, name, description) VALUES (:fid, :name, :desc)",
        {"fid": form_id, "name": body.name, "desc": body.description}
    )

    # 2. 批量登记字段定义到 meta_fields
    for field in body.fields:
        db.execute(
            """INSERT INTO meta_fields (field_id, form_id, field_name, field_type, is_required)
               VALUES (:fld_id, :fid, :fname, :ftype, :req)""",
            {
                "fld_id": f"fld_{uuid.uuid4().hex[:8]}",
                "fid": form_id,
                "fname": field.field_name,
                "ftype": field.field_type,
                "req": field.is_required,
            }
        )

    db.commit()
    return {"status": "success", "form_id": form_id}

@router.get("/forms/{form_id}")
def get_form_meta(form_id: str, db: Session = Depends(get_db)):
    """
    获取表单的完整元数据蓝图,前端据此渲染 UI。
    """
    form = db.execute(
        "SELECT * FROM meta_forms WHERE form_id = :fid", {"fid": form_id}
    ).fetchone()
    if not form:
        raise HTTPException(404, "Form not found")

    fields = db.execute(
        "SELECT field_name, field_type, is_required FROM meta_fields WHERE form_id = :fid",
        {"fid": form_id}
    ).fetchall()

    return {
        "form_id": form.form_id,
        "name": form.name,
        "fields": [dict(f._mapping) for f in fields]
    }

调用 POST /api/meta/forms 时,虽然在业务概念上我们"新建"了一张表,但数据库底层仅仅发生了普通的事务性 INSERT。没有任何表结构被改动,也没有触发锁。


小结

元数据驱动的核心并不是消灭了结构,而是做了一次巧妙的维度提升。 我们将传统数据库赖以生存的 Schema 从底层剥离,搬到了更高一层的"应用层数据字典"中。

在这个世界里,无论租户有多少,无论他们定义怎样千奇百怪的表单,底层物理依然是一张纹丝不动、便于统一治理和灾备的 JSONB 堆表。

下一篇预告:后端有了"JSON 蓝图",前端 Vue.js 是如何像搭积木一样将它们动态渲染成生动、可交互、带双向绑定的表单界面的?

点赞 + 关注 + 收藏 = 学会了

💡整理了一个 NAS 专属玩法专栏,感兴趣的工友可以戳这里关注 👉 《NAS邪修》

Audiobookshelf 是一个开源的、自托管的播客和有声书服务器。如果你手里有很多珍藏的有声书音频(比如评书、小说、外语听力),或者想集中管理订阅的播客,把它部署在 NAS 上,你就能拥有一个类似“喜马拉雅”或“小宇宙”的私人听书平台,不仅能记录播放进度,还支持多端同步!

这次我们以飞牛 NAS 为例演示部署过程,其他品牌(极空间、绿联、群晖等)的操作步骤也都大同小异

首先,打开 NAS 的 「文件管理」 应用,在 docker 文件夹里创建一个名为 audiobookshelf 的主文件夹。

接着,进入 audiobookshelf 文件夹,在里面再分别创建 4 个子文件夹:

  • audiobooks (存放有声书)
  • config (存放配置文件)
  • metadata (存放元数据)
  • podcasts (存放播客文件)

打开 「Docker」 应用,切换到 Compose 面板,点击新建一个项目:

  • 项目名称audiobookshelf
  • 路径:选择上一步创建的 /docker/audiobookshelf 文件夹路径
  • 来源:选择 创建 docker-compose.yml

在代码框中填入以下代码:

services:
  audiobookshelf:
    image: ghcr.io/advplyr/audiobookshelf:latest
    container_name: audiobookshelf
    ports:
      - 13378:80
    volumes:
      - /vol1/1000/docker/audiobookshelf/audiobooks:/audiobooks
      - /vol1/1000/docker/audiobookshelf/podcasts:/podcasts
      - /vol1/1000/docker/audiobookshelf/metadata:/metadata
      - /vol1/1000/docker/audiobookshelf/config:/config
    restart: unless-stopped

💡 参数:

  • volumes 下的每一项,都对应我们在第一步创建的子文件夹。请注意将冒号前的内容修改为你自己 NAS 的真实路径。
  • 13378 是给 Audiobookshelf 映射的本地端口,如果这个端口被占用了,你可以自定义改成其他数字(比如 13379)。

等项目构建完成后,切换到 容器 面板,找到 audiobookshelf 这一项。点击它旁边的链接按钮(或者在浏览器输入 NAS的局域网IP:13378 ),就能打开 Audiobookshelf 了。

初次进入系统,你需要先创建一个管理员账号。

登录成功后,默认是英文界面。

想要调成中文很简单: 点击右上角的 Settings(设置),找到 Default Server Language,在下拉菜单中选择 简体中文,界面就会瞬间变得亲切起来。

接下来是添加有声书,点击左侧导航栏的 媒体库,选择 添加第一个媒体库。类型选择有声书,文件夹映射路径选择 /audiobooks

然后打开 NAS 的 「文件管理」 应用,找到我们第一步建好的 /docker/audiobookshelf/audiobooks 文件夹,把准备好的有声书文件(mp3、m4b 等格式)放进去。

回到 Audiobookshelf 网页端,点击 扫描

扫描完成后,点击左上角的 Audiobookshelf 图标回到首页,就能看到刚刚添加进来的有声书。

点开即可选择章节播放。

如果你使用的是飞牛、绿联等国产新势力的 NAS,通过它们的移动端 App 穿透,在手机上也可以随时随地直接连上 Audiobookshelf 听书,非常方便!


以上就是本文的全部内容啦!你有在 NAS 上跑什么好玩、好用的 Docker 镜像推荐吗?欢迎在评论区留言讨论!

想了解更多NAS玩法记得关注《NAS邪修》👏

点赞 + 关注 + 收藏 = 学会了

大家好! Carry Code v0.7.3 正式发布了!这是一个使用 Rust 彻底重写的, 终端原生的 AI 代码代理,能通过自然对话帮助你编写、重构、调试和理解代码。

Q: Carry Code 为什么选择做 Cli 作为交互形式?

A: 我们认为编程的最终形式是黑灯工厂, 端到端实现需求定义到产品交付, 甚至无需人工介入;

📦 v0.7.3 更新

  1. 环境变量自动加载 Model API Key:支持从环境变量读取 Claude 、Gemini 、OpenAI 、OpenRouter 的密钥, carry命令可以直接兼容你的其他 cli 客户端;
  2. Skills 智能推理:大模型能自动选择合适的技能, 功能上对齐 claude code 和 openclaw;
  3. MCP 输出标准化:与 Bash 工具输出格式对齐,更直观;
  4. 原生 GPT-5.4/5.4 Pro 支持, 原生 OpenRouter 支持;

iShot_2026-03-08_18.23.02|690x484

✨ 核心亮点

🤖 双模式 Agent

  • Build 模式:自主生成和编辑代码
  • Plan 模式:只读分析和规划方案,安全可控

🧩 MCP 协议支持

  • 通过 Model Context Protocol 扩展智能体能力,用 /mcp 轻松管理

🎯 SKILL 技能系统

  • v0.7.3 新增 16 个内置技能,大模型会自动选择合适的技能来完成任务
  • 与 Claude Code Skills 兼容,可用 /skill 管理

iShot_2026-03-08_18.23.02|690x484

📋 AGENTS.md

  • 在项目根目录放置 AGENTS.md ,CarryCode 就会遵循项目专属规范

🎨 精美终端 UI

  • 渐变 Banner 、Markdown 渲染、语法高亮、代码 Diff 预览
  • 亮色/暗色主题一键切换

🔌 17+ 模型服务商

  • OpenAI 、Claude 、Gemini 、DeepSeek 、Kimi 、GLM 、MiniMax 、通义千问、Ollama 、vLLM 、OpenRouter……总有一款适合你

🚀 240+ SOTA 模型

  • GPT-5.4/5.4 Pro 、Claude Opus/Sonnet 4.6 、Gemini 3 Pro/Flash 、Kimi 2.5 、DeepSeek V3.2 、Qwen3 等业界最新模型的原生支持(准确上下文适配和多模态识别).

🗜️ 智能上下文压缩

  • 自动压缩长对话,适应 Token 限制同时保留关键信息

📥 安装

 1 │ # MacOS / Linux
 2 │ curl -fsSL https://carrycode.ai/install.sh | sudo sh
 3 │ 
 4 │ # Windows
 5 │ irm https://carrycode.ai/install.ps1 | iex

 1 │ # 运行
 2 │ carry

或使用单次模式:

 1 │ carry --once "解释这个函数"

快来试试吧!🎉

业余做了个 AI 文字修仙游戏(凡人修仙传世界观),累计四五百人玩过,有人一口气打了五百回合。

技术栈是 Vue 3 + CloudBase + DeepSeek ,流式 SSE 输出。做的过程中在上下文管理上踩了不少坑,分享几个我觉得比较有价值的:

上下文不是内存,是注意力带宽

一开始用 history.slice(-10) 就够了。后来有人玩了五百轮,加到 35 轮历史反而更差——AI 搞混剧情线,在不该突破的时候写突破。

后来想明白了:塞太多进去不是"记住更多",而是每条信息都被更少地关注。

让 AI 自己做摘要

压缩历史的时机很关键。调额外的 AI 来压缩?用户等太久。最后发现让 AI 写故事时顺便输出 30 字摘要是最优解——刚写完 300 字,对"发生了什么"理解最准确,摘要几乎零成本。

做了三层分级:最近十几轮完整保留,再往前用摘要替代,更早的压缩成章节摘要。150 轮从 50000 字压到 10000 字。

堵不如疏(这是最大的教训)

储物袋 80 件上限,AI 不知道,继续写"你拿到了聚灵丹",前端拦截了——玩家看剧情说拿到了,打开背包没有。

本能反应是在 System Prompt 加规则。一条不够加两条,语气从"禁止"升级到"严禁"。光一月十八号一天就改了 8 次提示词。385 行膨胀到 654 行,效果越来越差。

后来换了思路:代码检测到物品超阈值,就在上下文末尾动态注入一条警告。物品清理后 condition 变 false ,警告自动消失。

30 条规则永驻上下文,AI 条条违规。每次只注入 1-3 条最相关的,条条遵守。不是 AI 变聪明了,是它不用同时关注 30 件事了。

就像家长同时念叨"坐直!作业呢?别玩手机!早点睡!"——小孩一条没听进去。但过马路时只说一句"看车",立刻照做。

选题材比写提示词重要

有玩家夸上下文管理做得好,其实那时候就一个 slice(-10)。效果好纯粹是题材红利——凡人修仙传在网上有海量素材,AI 训练时学了一大堆。选一个 AI 本身就"懂"的题材,比费劲写提示词教它有用得多。


71194ec3a4d74024.webp

游戏在这里,感兴趣可以试试: https://fanren.idealeap.cn/

邀请码:FTWK3TAM

欢迎交流,也欢迎来玩然后骂我

和 AI 对话超过 20 轮之后,看着它慢慢开始胡说八道,如果有过这种经历,那么你就应该看看这篇论文

跟 AI 聊天机器人对话时,用户输入的每一个字都会被保存,模型给出的每一条回复同样会被保存。所有历史内容在下一轮对话中被回传给模型,再下一轮,再下一轮,像河底的沉积物越堆越高。

每一个聊天机器人、每一个 AI Agent、每一个多轮对话系统都按这个方式运行。看起来理所当然,模型不存自己的回复,怎么"记住"之前说了什么?

重大发现有时源于一个不起眼的问题。而直到最近才有人问出来:如果存储 AI 自己的回复,反而在拖累它的表现呢?

MIT 在 2026 年 2 月发表了一篇论文来回答这个问题。标题刻意低调——"Do LLMs Benefit From Their Own Words?"——但结论一点都不低调。

测试一个没人质疑过的假设

实验设计很简单,正因如此结果才格外有意思。

研究人员从 WildChat 和 ShareLM 中抽取了真实的、杂乱的、来自实际使用场景的对话——不是精心构造的合成 benchmark,而是真实用户和 AI 系统聊真实话题的记录。他们在四个模型上用两种方式分别跑了一遍:Qwen3–4B、DeepSeek-R1–8B、GPT-OSS-20B 和 GPT-5.2。

 # 条件 A - 标准方式(今天每个聊天机器人都在做的事)  
 context = [user_1, assistant_1, user_2, assistant_2, …]  
 # 条件 B - 省略助手回复(没人尝试过的做法)  
 context = [user_1, user_2, user_3, …]  
 # 去掉所有之前的 AI 回复。只保留人类的消息。  
 # 然后比较质量。就这样。这就是整个实验。

简单,大胆。结果呢?

Removing prior assistant responses does not affect response quality on a large fraction of turns. Omitting assistant-side history can reduce cumulative context lengths by up to 10×.

上下文长度缩减约 10 倍,回复质量几乎不变。多轮提示中 36.4% 完全自包含,根本不需要任何历史记录;约 70% 的对话轮次要么不需要历史,要么仅凭用户消息就能重建上下文。

上下文污染的机制

典型的聊天过程:提一个问题,AI 回复,再追问。

但底层实际发生的事情更可能跟我们的理解不太一样:模型在处理追问时,看到的并不只是新的提问,而是新提问加上它之前给出的每一条回复的全文,包括其中所有的错误、幻觉、措辞偏差,以及几轮前引入的错误假设。

所以模型没有任何特殊标记来区分"这是我自己之前的输出"和"这是可信的外部信息"。它读取自己过去回复的方式,和读取 ground truth 完全一样。第二轮里自信说错的东西,第三轮会在上面继续往下搭,第四轮、第五轮照搬不误——每一轮都进一步偏离事实,同时愈发笃定。

论文给这种现象起了个名字。当模型过度依赖先前的回复,锁定早期的错误、幻觉或文体惯性并将其向后续轮次传播时,称为 context pollution——上下文污染。早期的偏差经由反馈循环不断放大。

MIT 团队选的这个术语很准确。长对话中观察到的质量滑坡并非随机的系统疲劳。

模型自己的声音才是污染源。

从 prompt 中删掉 AI 过去的回复,省下的并不只是算力和 Token 空间,更关键的是切断了模型饮用自己毒水的通路。

大多数对话并不需要想象中那么多历史

去掉 AI 的回复还能拿到质量相当的答案,为什么?论文给出的解释很直观,听完会觉得奇怪为什么没有人更早意识到。

多数对话轮次在本质上是自给自足的,真实多轮对话中 36.4% 的提示完全独立,跟之前的交互没有任何关联。另外约三分之一虽然引用了先前的助手回复,但其中并不包含任何可供模型利用的新信号。

两部分加起来,约 70% 的典型对话中,AI 存储的历史要么是无关噪声,要么更糟——失真的来源。一轮一轮忠实地把模型自己的话回传,大多数时候毫无帮助,有时候反而在拖后腿。

不是"一律删除",而是选择性过滤

别带着"论文让永远删掉所有对话历史"的印象离开。它没有这么说。

研究人员明确指出了一个限定条件。不同模型的表现并不一致:对于开源推理模型——DeepSeek-R1–8B 和 GPT-OSS-20B——有没有助手历史记录,回复质量基本持平;而 GPT-5.2 作为能力更强的闭源模型,移除助手历史确实导致了一定的质量下降。能力更强的模型似乎能从自身先前的上下文中提取更多有用信号,也更擅长利用这些上下文而不被带偏。

论文的主张不是全面省略,是选择性过滤。研究团队为此训练了一个分类器,逐轮判断保留 AI 之前的输出对当前回复究竟有益还是有害。在这种自适应省略策略下,回复质量和上下文缩减同时得到改善。明智的做法不是最大化上下文,而是只保留必要的上下文。

对现有每一个 AI Agent 的影响

AI Agent——那些部署来写代码、浏览网页、管理文件、在循环中回答客户问题的系统——运行起来动辄几十轮甚至上百轮。

每个 Agent 框架都存储完整轨迹:工具调用、中间推理步骤、每一条回复。上下文随对话长度线性增长,触及上限后,Cursor、Claude Code 这类系统开始压缩和裁剪,只为维持运转。这些手段本质上是搭建在一个有缺陷的假设之上的工程补丁。

这项研究指出默认策略应该翻转。问题不该是"什么时候修剪?"而该是"为什么要存储这些?"没有具体且合理的理由,就不要保留助手的回复。这是一种根本不同的设计哲学,会实质性地改变系统的构建方式。

过去数年,行业一直在追逐更长的上下文窗口——128K Token、1M Token,竞赛的主题始终是"装进更多内容"。没有人停下来问过:塞进去的大部分内容是否真的在发挥作用。

模型自己的话,可能是上下文窗口中价值最低的部分;在上下文污染发生时,反而是危害最大的部分。

其他论文中已有端倪

多轮 AI 对话比看起来更脆弱,这不是第一次出现信号。

微软 2025 年发表的研究得出了一组互补的结论:LLM 在多轮欠定义对话中的任务表现平均只有约 65%,比单轮场景下 90% 的表现低了 25 个百分点。论文将这种现象命名为 "lost in conversation"——模型一旦在早期走错方向,不会自我纠正,而是螺旋式恶化。

Chroma 同年发表的研究识别出一个相关现象,称之为 "context rot":随着输入长度增长,模型表现变得越来越不可靠,即便在简单的检索任务上也如此。测试覆盖了十八个不同模型,包括 GPT-4.1、Claude 4 和 Gemini 2.5。所有模型在长输入下都出现了退化——不是平滑的衰减,而是不规则的波动。

另一项关于 "context branching" 的独立研究发现,当上下文在多轮对话中逐渐被污染时,开发者经常遇到"看似合理但实际错误的解决方案",在探索性编程中尤为普遍——早期的错误假设持续累积,且无法在不重新开始对话的情况下回退。

Chroma Research (2025) · "Context Rot: How Increasing Input Tokens Impacts LLM Performance"

Laban et al. (2025) · "LLMs Get Lost In Multi-Turn Conversation" · Microsoft Research

总结

对于日常依赖 AI 工具的使用者——无论是编码助手还是研究型 Agent——这篇论文要求重新审视工作习惯。长对话直觉上让人觉得模型会"更聪明",因为上下文更多。事实恰好相反:对话进行了二十轮的模型很少比一个全新会话更准确,多数时候只是深陷在自己累积的错误里。点击"新建对话"不是在丢失上下文,有时只是在清除毒素。

对于系统构建者,默认的架构——将每一轮对话堆叠到窗口塞满——不仅浪费算力、增加延迟,还在通过自我强化的错误循环主动拉低输出质量。Agent 设计的下一个前沿不在于更好的压缩算法,而在于动态的、选择性的省略。

抛开 10x 的效率增益和架构层面的争论,这项发现还有一层更深的意味。过去几年行业构建了能对话的系统,然后强迫这些系统无休止地听自己说话,默认把自我引用等同于记忆。

证据表明两者并不等价。支撑所有多轮 AI 系统的基础假设多年来未经审视,而在构建下一个十年的 Agent 架构时,一个令人不安的结论浮出水面:有时候,AI 能做的最明智的事,是忘掉它刚才说了什么。

论文

https://avoid.overfit.cn/post/64b53523db514c199ce7b36c120abb39

by Ship X/ TechX

1、RAG介绍

RAG(Retrieval-Augmented Generation,检索增强生成)是一种结合信息检索与文本生成的先进AI架构,其核心在于让大语言模型在回答问题前,先从外部知识库中“查找资料”,再基于查到的信息生成准确、有依据的回答。这种方法有效缓解了大模型常见的知识过时、幻觉等问题。
在这里插入图片描述

1.1、RAG基本原理

RAG的工作流程可分为三个关键阶段:数据准备 → 检索 → 生成,形成一个“先查后答”的闭环机制。

  • 数据准备(索引阶段):将企业文档、网页、PDF等非结构化数据加载并切分为小块(chunking),例如每段300–800字符。 使用嵌入模型(如text-embedding-3-small)将文本块转化为向量,并存储于向量数据库中,便于后续语义检索。
  • 检索阶段(Retrieval): 当用户提问时,系统将问题也转化为向量。 在向量数据库中通过相似度匹配(如余弦相似度)检索出最相关的若干个文本片段。 可结合关键词检索(BM25)与语义检索(DPR)进行多路召回,提升召回率与精准度。
  • 生成阶段(Generation): 将检索到的相关片段与原始问题拼接成提示词(Prompt),输入大语言模型。 模型基于这些“参考资料”生成最终回答,确保内容有据可依,减少虚构风险。

RAG的准确率瓶颈本质上是“检索上下文质量”的瓶颈。如果检索不到正确信息,再强的生成模型也无法给出正确答案。

1.2、RAG应用场景

RAG因其灵活性和高准确性,已在多个领域实现落地应用,尤其适合需要专业性、实时性、可解释性的场景。

  • 企业知识库问答:员工可通过自然语言查询内部制度、产品手册、项目文档。 无需人工整理,系统自动检索并生成摘要,提升信息获取效率。
  • 智能客服与售后服务:客户咨询产品功能、退换货政策时,RAG可实时检索最新服务条款,避免因信息滞后导致误答。 支持个性化回复,如结合用户历史订单生成定制化建议。
  • 医疗与法律辅助决策:医生可输入患者症状,系统检索最新诊疗指南或临床研究,辅助诊断。律师查询合同条款时,RAG能从历史案例或法规库中提取相关判例,提升合规性。
  • 学术研究与文献综述:研究者提出研究问题后,RAG可快速检索大量论文摘要,并生成初步综述框架。节省查阅资料时间,提高科研效率。
  • 动态内容生成与新闻撰写:结合实时数据(如股市行情、体育赛事结果),RAG可生成带最新信息的报告或新闻稿。适用于财经、体育、舆情监控等对时效性要求高的领域。

1.3、RAG核心技术

RAG的核心技术组成主要包括以下几个关键部分:

  • 信息检索模块(Retrieval Module):负责从大规模文档库中检索与用户查询最相关的文档片段。通常使用向量数据库(如Faiss、Pinecone、Milvus)存储文档的向量表示,通过计算查询向量与文档向量的相似度来实现快速检索。检索算法可以是基于关键词的(如BM25)或基于语义的(如DPR、Sentence-BERT)。
  • 嵌入模型(Embedding Model):用于将文本(文档和查询)转换为固定长度的向量表示,以便进行语义相似度计算。常用的嵌入模型包括:Sentence-BERT(SBERT)、OpenAI的text-embedding模型、通义千问的QwenEmbedding等。嵌入模型的质量直接影响检索效果。
  • 生成模型(Generation Model):通常基于大语言模型(LLM),如GPT系列、通义千问、Llama等。接收检索到的相关文档片段和原始查询作为输入,生成最终的回答。生成模型需要具备良好的上下文理解和语言生成能力。
  • 检索-生成融合机制(Retrieval-Generation Fusion):将检索到的文档片段与原始查询组合成提示(Prompt),输入到生成模型中。这个过程可以是简单的拼接,也可以是更复杂的融合策略,如注意力机制。
  • 向量数据库(Vector Database):用于高效存储和检索高维向量数据。支持快速的近似最近邻(ANN)搜索,是实现大规模文档检索的关键。常见的向量数据库包括:Faiss、Pinecone、Milvus、Weaviate等。
  • 数据预处理与后处理:数据预处理包括文档清洗、分块、去除无关内容等,以提高检索效率和质量。后处理可能包括答案过滤、格式化输出、引用标注等,以提升最终回答的可读性和可信度。这些组件协同工作,使得RAG能够在保持大语言模型强大生成能力的同时,通过外部知识库提供更准确、更可靠的问答结果。

上述组件协同工作,使得RAG能够在保持大语言模型强大生成能力的同时,通过外部知识库提供更准确、更可靠的问答结果。
本文选型 “Milvus(向量数据库)、Qwen(生成模型)、Qwen-embedding(嵌入模型)及SpringAI” 讲述及实践。

2、向量数据库

向量数据库是专门用于存储、管理和高效检索高维向量数据的新型数据库系统,它能将文本、图像、音频等非结构化数据,通过AI模型转化为蕴含语义特征的向量序列,再基于向量间的相似度实现“语义级检索”,解决传统数据库在非结构化数据处理上的局限性,为RAG智能问答、多模态搜索、智能推荐等AI应用提供底层支撑。

2.1、核心工作步骤

  • 数据向量化:生成“特征指纹”。这是向量数据库的前置核心环节,需借助Embedding模型将原始非结构化数据转化为高维向量,同时要平衡向量维度:维度越高特征表达越精细、检索精度越高,但存储和计算成本会指数级增长;维度越低效率越高,但可能丢失关键特征导致精度下降,工业级常规选择文本768-1536维、图像512-2048维。

    • 文本数据:可选用OpenAI的text-embedding-ada-002(通用场景最优,1536维)、国产开源的BGE(性价比之选,768维)、微调后的BERT(细分领域首选)等模型。
    • 图像数据:CLIP(支持文本搜图的多模态适配模型)、ResNet(纯图像特征提取模型)是常用工具。
    • 音频数据:Wav2Vec2(语音转向量)、VGGish(音频场景特征提取)可满足需求。
  • 存储与索引构建:加速相似性计算。向量数据库会将生成的高维向量存储起来,并构建特殊索引结构来提升检索效率,常见索引算法有IVF(倒排文件)、HNSW(分层可导航小世界图)等,它们能大幅降低相似性计算的耗时。
  • 相似性检索:找“最近邻”。当用户发起查询时,系统先将查询内容转为向量,再在数据库中寻找与其“距离最近”的Top-K个向量,常用的距离度量方式有三种:

    • 余弦相似度:最常用,只关注向量方向、忽略长度,适合语义级对比,如文本检索,计算结果取值范围[-1,1],越接近1相似度越高。
    • 欧氏距离:计算两个向量的直线距离,同时考虑方向与长度,适合关注绝对特征差异的场景,如图像检索,值越小相似度越高,需提前对向量进行归一化处理。
    • 点积相似度:计算速度最快,但受向量长度影响大,对向量进行L2归一化后,结果等价于余弦相似度,适合高并发低延迟场景,如实时推荐。

3、Milvus介绍

Milvus 是一款专为高维向量数据设计的云原生向量数据库,广泛应用于人工智能、机器学习和相似性搜索场景。它采用存储与计算分离的架构,具备高可用性、高性能和弹性扩展能力。

在这里插入图片描述

3.1、核心架构层次

Milvus 的系统架构分为四个主要层次:

  • 接入层(Access Layer):作为系统的入口,由一组无状态的 Proxy 组件构成,负责请求路由和负载均衡。
  • 协调服务(Coordinator Service):管理元数据、任务调度和状态同步,包括 Root Coordinator、Data Coordinator 和 Index Coordinator 等。
  • 执行节点(Worker Node):处理实际的数据插入、查询和索引构建等操作,包含 Query Node、Index Node 和 Data Node。
  • 存储层(Storage Layer):负责持久化存储,使用对象存储(如 S3、MinIO)来保存向量数据和索引文件,同时通过 etcd 和 Pulsar/Kafka 管理元数据和日志。

3.2、数据模型与存储机制:

维度Milvus关系型数据库说明
数据组织结构Database → Collection → Partition → Segment → EntityDatabase → Table → RowMilvus 以 Segment 为最小存储单元,支持分片;关系库以页或块为单位
存储介质对象存储(S3/MinIO)+ 元数据存储(etcd)+ 消息队列(Pulsar/Kafka)磁盘文件 + 日志(Redo Log)Milvus 使用对象存储持久化数据,元数据由 etcd 管理;关系库依赖本地存储
索引机制支持多种 ANN 索引(HNSW、IVF、FLAT 等)B-tree、Hash、Bitmap 等Milvus 为高维向量优化索引,支持近似搜索;关系库为低维结构化字段设计

3.3、术语映射关系:

Milvus 术语关系型数据库术语说明
DatabaseDatabase数据库是组织和管理数据的逻辑单元。为了提高数据安全性并实现多租户,你可以创建多个数据库,为不同的应用程序或租户从逻辑上隔离数据。Milvus 在集合之上引入了数据库层,为管理和组织数据提供了更有效的方式,同时支持多租户
CollectionTable数据集合,定义字段结构。用于存储和管理实体的主要逻辑对象。
PartitionPartition集合内的物理分区
SegmentPage / Block定义数据类型和数据属性的元信息。每个 Collections 都有自己的 Collections Schema,该 Schema 定义了 Collections 的所有字段、自动 ID(主键)分配启用和 Collection 说明
FieldColumn字段类型支持标量与向量
EntityRow单条数据记录
IndexIndex向量索引,类型多样

4、Milvus本地部署

4.1、Docker Compose 部署

Milvus 提供了 Docker Compose 配置文件:

wget https://github.com/milvus-io/milvus/releases/download/v2.6.11/milvus-standalone-docker-compose.yml -O docker-compose.yml

sudo docker compose up -d

Creating milvus-etcd  ... done
Creating milvus-minio ... done
Creating milvus-standalone ... done

启动完成后可以访问 Milvus WebUI网址( http://127.0.0.1:9091/webui/ )了解有关 Milvus 实例的更多信息。

4.2、Attu(可视化工具)安装

Attu是 Milvus 官方推出的图形化管理工具,提供直观的可视化界面,方便用户查看和管理向量数据库。通过 Attu,用户可以轻松完成数据库架构设计、数据操作、向量搜索等复杂任务,大大降低 Milvus 的使用门槛。

docker run -d --name milvus-attu \
  -p 8000:3000 \
  -e MILVUS_URL=localhost:19530 \
  zilliz/attu:v2.6

Attu 启动完成后可以访问( http://localhost:8000 ),以图形化方式查看和管理Milvus 实例。
在这里插入图片描述

5、模型本地安装

RAG系统依赖Embedding与Generation两类模型:

  • 嵌入模型(Embedding Model):用于将文本(文档和查询)转换为固定长度的向量表示,以便进行语义相似度计算。常用的嵌入模型包括:Sentence-BERT(SBERT)、OpenAI的text-embedding模型、通义千问的QwenEmbedding等。嵌入模型的质量直接影响检索效果。
  • 生成模型(Generation Model):通常基于大语言模型(LLM),如GPT系列、通义千问、Llama等。接收检索到的相关文档片段和原始查询作为输入,生成最终的回答。生成模型需要具备良好的上下文理解和语言生成能力。

本文分别选择 “qwen3-embedding” 与 “qwen3.5” 作为嵌入模型与生成模型,Ollama本地安装如下;

admin@Mac-miniM4 milvus % ollama list
NAME                                ID              SIZE      MODIFIED    
qwen3.5:2b                          324d162be6ca    2.7 GB    3 hours ago    
qwen3-embedding:0.6b                ac6da0dfba84    639 MB    4 hours ago    

6、RAG系统设计

RAG 知识库的核心价值在于「结构化检索(关系型)+ 语义检索(向量)」的融合,实体模型设计需同时兼顾关系型数据的结构化关联能力和向量数据的语义匹配能力,既要保证实体间的逻辑关联清晰,又要实现基于语义的精准检索。以下聚焦「关系型 + 向量数据融合」的实体模型设计,包含核心实体定义、数据存储分工、关联逻辑、落地实现四大核心模块。

6.1、核心设计原则(融合版)

  • 分工明确:关系型数据库(MySQL)存储「实体元数据、关联关系、检索过滤条件」,向量数据库(Milvus)存储「文本语义向量」,避免单库承载所有压力;
  • 双向关联:关系型数据与向量数据通过唯一 ID(chunk_id)绑定,支持「从关系型维度筛选→向量语义检索」「从向量检索结果→回溯关系型元数据」;
  • 轻量化融合:向量数据仅存储核心检索单元(Chunk)的向量,不冗余存储文档 / 实体的全量向量,关系型数据补充向量无法表达的结构化信息(如实体类型、文档来源)。

6.2、核心实体模型(关系型 + 向量融合)

实体分工总览

数据类型存储载体存储内容核心作用
关系型数据MySQL/PostgreSQL文档 / Chunk / 业务实体的元数据、实体间关联关系、检索过滤字段(状态 / 租户 / 类型)结构化筛选、实体关联、结果回溯
向量数据Milvus/PGVector/FAISSChunk 的 Embedding 向量、向量索引(IVF_FLAT/HNSW)语义相似度检索

关系型实体表设计(核心元数据 + 关联)

  • Knowledge(知识库实体,关系型):存储知识库定义元数据,维护知识库Embedding模型、向量数据库设置信息。
  • Document(文档实体,关系型):存储文档级结构化元数据,是所有子实体的根节点,不存储完整内容和向量。
  • Chunk(文本块实体,关系型):
    存储 Chunk 的元数据,仅保留向量 ID(与向量库绑定),不存储原始向量,是关系型与向量数据的核心桥梁。
  • 向量数据模型设计(语义检索核心)
    Milvus 中创建「knowledge_vector_collection」集合,与关系型 Chunk 表的vector_id一一对应:

7、RAG关键代码

7.1、Maven依赖引入

使用SpringAI进行模型与向量数据库集成,需要添加如下依赖:

<!-- milvus -->
<dependency>
    <groupId>io.milvus</groupId>
    <artifactId>milvus-sdk-java</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

7.2、知识数据向量化入库

核心流程为:「文档分块 → 向量化 → Milvus 入库」

// 1、初始化Embedding模型
EmbeddingModel embeddingModel = OllamaEmbeddingModel
                .builder()
                .defaultOptions(OllamaEmbeddingOptions
                        .builder()
                        .model(EMBEDDING_MODEL_NAME)
                        .dimensions(VECTOR_DIMENSION)
                        .build())
                .ollamaApi(ollamaApi)
                .build();

// 2、知识文档正文分块
List<String> chunks = splitDocument(doc.getContent());

// 3、Chunk文档向量化处理
List<float[]> vectors = embeddingModel.embed(texts);

// 4、知识数据向量化入库
List<JsonObject> vectorData = process(vectors);
UpsertResp upsertResp = client.upsert(UpsertReq.builder()
                .collectionName(collectionName)
                .data(vectorData)
                .build());

7.3、知识相似度检索

核心流程为:「问题向量化 → Milvus 检索」

// 1、问题向量化
float[] keywordVector = embed(List.of(keyword)).get(0);

// 2、向量 检索
SearchReq searchReq = SearchReq.builder()
                    .collectionName(buildCollectionName(kbId))                                  
                    .data(Collections.singletonList(new FloatVec(keywordVector)))  
                    .annsField("contentVector")                                                     
                    .outputFields(Arrays.asList("id", "chunkId", "contentVector"))     
                    .limit(TOP_K_COUNT)
                    .searchParams(Map.of("radius", SIMILARITY_THRESHOLD))   // 相似度阈值
                    .build();
SearchResp searchResp = client.search(searchReq);

7.4、知识库系统交互

知识库系统交互见下文,支持针对文档进行新建、管理、向量化/Embedding、相似度检索等操作。为RAG、
部分截图如下:

  • 知识库管理:
    在这里插入图片描述
  • 知识相似度检索:
    在这里插入图片描述

⚠️

1. 发现经过

  • 配置时间:大前天开启。
  • 具体情况:配置了 GCP Vertex AI,但是由于 openclaw3.2 bug 实际上没有跑通,后面换了别的。昨日发现突然 VertextAi 费用高到 $179 刀,经排查发现是 openclaw 再大前天就被扫了。。
  • 损失金额:短时间内产生了 $179 的高额扣费。

2. 安全建议

强烈建议大家注意安全,不要轻易使用厂商提供的 One-key (一键)脚本! 这种脚本权限往往不可控,极易导致 API 被刷或配置失衡。

3. 现场截图

腾讯云 1

腾讯云 2

一. 前提说明

客观上介绍了个人站 HelpAIO, 为了体验放在文章末尾

二. 使用中转前应该知道的

了解下面的信息, 基本能帮助入门, 避坑

1. 不用中转,自己开账号可以吗

省流: 能的, Claude Code Max20 自己开完一个月, 比任何中转都便宜

  • Claude Code: 多维度风控, 人称 A➗, 严重到肉身在国外开, 正常用都封号. 老号风控较轻, 新号风控很严格, 建议不要直接开 Max20, 分发的话更容易被封; 这个确实不是在传播焦虑(经常能在 v2,推特等地方看到“如何防封”);


2. 中转能不能看到我的数据, 有没有可能卖数据, 恶意投毒

省流: 理论上能, 实际站长更想赚钱.

使用中转站就相当于 HTTPS 中间人攻击, 理论上能实现的数据收集, 也能投毒.

我个人大概想法是去饭店不能去后厨看, 否则可能吃不下饭, 尽量找靠谱的馆子吃饭.

对于大型中转来说, 稳定本身就是一种商誉价值.


3. 中转倍率是什么

中转站一般面向国内用户, 倍率是模型的美元价格转成人民币计价后的比例.
一般默认 1 元=1 美刀, 也就是 1:1, 下面是一个示例.

  • 1 倍:1 元 换 1 美元
  • 1.5 倍:1.5 元 换 1 美元
  • 7 倍:7 元 换 1 美元


4. 中转渠道说明

省流: 一定要问清楚中转提供的是什么渠道, 官方和逆向价格差 5~6 倍.

在一切开始前, 要先介绍渠道, 渠道大概分为两种.

  • 官方渠道:
    • 来源: 如Claude Max20x 订阅, Claude 官方 API, Aws, Azure这种官方或一线服务商
    • 优点: 公认效果最好, 模型能力最强, 但 Claude 官方订阅仅限 Claude Code 中使用(甚至不支持自家 sdk 用)
    • 缺点: 普遍较贵, 一小时 20~30 块正常, 建议选购价, 0.85~1.5 元每刀
  • 逆向渠道:
    • 来源: 如 Kiro, Cursor, Windsurf, Awsq, Warp 等一些编辑器或终端本身集成了, 逆向出来给客户用
    • 优点: 可以在 Claude Code 外面使用, 价格一般是官渠的 0.2~0.3 倍, 少数如反重力模型能力很强的可以到 0.6 倍率;
    • 缺点: 大部分逆向模型能力有缺陷, 比不上官方
      • 智商低: 逆向渠道可能带固定的提示词, 能力不如官方
      • 没缓存: 有些渠道没缓存, 缓存是站长加的(比如写死 80%~88%)
      • 工具调用失败: MCP, Skill, Tool 调用失败, 或调用也无法正常完成任务
      • 上下文短: 比如 Claude Code 官方是 200K 上下文, 逆向渠道可能是 128K
      • 断流: 经常用着用着就断了


5. 中转会不会掺假

省流: 会的, HelpAIO 发现过, 包括前几天都有论文实锤了.

问就是上游掺假

中转为了提高 SLA 可用率, 除了自己的号池外, 还会找一些其他渠道, 也就是上游, 这个客观存在
包括中转站之间都可能互为上游.

现在普遍情况就是, 如果被用户发现就说是其他渠道, 上游的锅.
大概就是: 上游可能谈的时候给的是 max, 等用了就会给你惨点儿逆向, 类似直播带货的 AB 货.

当然也有站长自己发现, 然后主动公告给用户退款.

(1) 怎么惨?

  • 正常官方, 官方挂了逆向
  • 官方+逆向: 混着卖, 比例不定, 看站长良心, 有可能 9:1, 有可能 1:9
  • 官方+其他模型
  • 其他模型(GLM, DeepSeek, Gemini)

(2) 几种情况

  • 最好: 站长主动发现自己的上游渠道有问题, 主动公告并退款
  • 一般: 问是不是纯官方, 会说惨的, 或者沉默
  • 略差: 被用户发现, 答复上游惨的
  • 差的: 后台偷偷换, 用户发现就 T 了


6. 中转好不好的核心

  • 渠道: 上面已经说了
  • 价格组成:
    • 输入: 读取文件的花费
    • 输出: 写入文件的花费
    • 缓存创建: 读过的文件, 可以创建缓存, 后续读取缓存更便宜. 缓存创建价格如 Claude Code, 5 分钟缓存价格是输入的 1.25 倍, 1 小时缓存价格是输入的 2 倍
    • 缓存读取: 从缓存里面读取, 价格是输入的 0.1 倍
  • **缓存率: “价格” 只是一个显眼指标,缓存率同样重要, 缓存率差 10%, 价格便宜的渠道可能更贵
    • 参考缓存率: 一线中转 Claude Code 缓存率 80%以上, Codex 缓存率 85%以上
    • 统计缓存率:
      • 网页的Claude Code Hub: 网页版, 适合团队用户, 可部署在服务器或 Docker
      • HelpAIO 开源的aio-coding-hub: Tauri2 开发的本地客户端, 支持 Mac, Win, Linux
  • 可用率: 价格再低, 不可用等于 0

上面的信息, HelpAIO都有

  • 价格收集, 比价: 目前主要收集 Claude Code 官方渠道(Max)
  • 可用率检测: 7*24 小时不间断检测 Claude Code, Codex 可用率
  • 渠道说明: 官方, 逆向, 官逆混用
  • 优缺点, 近期体验: 自己作为用户的一些体验分享


7. 其他风险

  • 中转作为下游, 受到上游政策影响, 可能随时调整价格.
  • 中转可能随时不可用, 如某 8 站之前经常出现.
  • 中转可能随时跑路, 某 P 站至今退款难, 甚至起诉用户(太抽象了); 某 8 站持续不可用, 停摆两个月, 不过近期开始恢复并退款.


8. HelpAIO 是怎么排名的

  • 基础分
    • 服务设施分 65%: 后台有 50+个指标, 所有中转都在同一指标下进行打分得出(个人深度使用的经验看服务设施非常重要!).
    • 模型价格分 35%: 模型价格便宜, 分越高.
  • 动态排名: 最终排名=基础分✖️3 日模型可用率, 防止宕机很久了排名还很高.
  • 新站收录: 收录小于 3 天, 权重额外✖️0.8, 收录大于 3 天小于 7 天权重额外✖️0.9(测试无法 24 小时不间断, 降权是为了收集可用率).
  • 渠道争议: 发现模型与描述渠道不符(比如承诺 Max 渠道, 惨逆向), 前 10 天权重额外✖️0.3, 后 20 天线性恢复.
    Xnip2026-03-08_14-22-18.png


三. 写在最后

HelpAIO 是我第一个创业项目, 借着 AI 的东风, 也说一些迭代到现在的一些想法.
一家之言, 欢迎讨论.

code is cheap, show me the idea

不知道大家知不知道, 微舆这个开源项目作者发布 3 个月被盛大陈天桥投资 3000 万, 这个项目就是 idea 价值的一个体现

对于陈天桥来说他真的是看好这个项目吗, 个人感觉是一种更多是"紧迫感", 自己不革命, 就要被别人革命的紧迫感

是否是真的其实不是关键, 但能看到的是AI 是工业革命级别的机会, 不断涌现项目, 可以说 AI 缩小了差距, 有 idea 和行动力, 比单纯的开发能力更重要.

不要有完美的"执念"

很多开发者对自己有很高的要求, 要搞得满足自己的要求才发布, 结果搞着搞着自己怎么都不满意, 最终放弃了.

但 AI 来了, 试错的成本已经无限降低, 现在一个天能完成以前一个周工作, 今天不发布, 明天可能就被别人发布了

快速验证接受市场检验越来越重要

重视用户的反馈

作为 PM, 要乐于接受用户的反馈, 合理的规划需求.

我特别感谢一个网友,这个网友对当时那个版本的 HelpAIO 评价大概意思是: "有用但有点儿花里胡哨, 没有重点"

是这个评价让我快速优化了几个大版本, 实用性增强了不少

快速迭代

AI 磨平了差距, 也带来了弊端, 产品一旦在市场上被发现, 可能就会被快速 copy.

你的项目可能好, 但是可能不如别人会营销. 一定要重视营销, 快速迭代. 如最近出现的openclaw 三省六部开源项目疑似抄袭 原作者 300star, 而该仓库 5000star

总之要快速持续迭代.

最后欢迎大家使用 HelpAIO 了解中转, 希望能帮助大家选到合适的中转站.
中转评测: https://www.helpaio.com/transit

中转比价: https://www.helpaio.com/transit/compare

image.png

中转可用率: https://www.helpaio.com/transit/availability

image.png

👉 访问地址:免费 SSL 证书签发

前言

兄弟们,最近运维圈有个大事:2026 年 3 月 15 日起,行业新规正式生效。原本能管一年的付费证书,最长有效期也被强行砍到了 200 天

这就非常尴尬了:以前很多人花钱买证书是为了“一年才折腾一次”,现在既然连付费的都要半年折腾一回,那跟 90 天有效期的 Let's Encrypt 相比,手动更新的性价比已经彻底没了。

既然横竖都要频繁折腾,为什么不直接搞个“全自动续期”?上周我因为忘记续期导致子域名报不安全,被吐槽了一顿,痛定思痛,我自己撸了一个自动化工具分享给大家。

为什么要搞这个?

目前市面上的工具要么太笨重(要装各种 Certbot 依赖、跑命令行),要么太封闭(云厂商工具只管自家的,跨云就不灵了)。

我的需求很简单:

  1. 别让我注册(这种救急工具,注册流程真的很拦人)。
  2. 别让我手动改 DNS 验证(手动加 TXT 记录太反人类,还容易忘删)。
  3. 申请完能直接拿走,也可以一键托管。

功能亮点

  • 🚀 免注册领证:输入域名和解析商 API,后台自动跑脚本,直接出证书 ZIP 包(含有 .crt 和 .key),拿走即用。
  • ☁️ 多云适配:目前跑通了阿里云、腾讯云的 DNS API,自动添加验证记录并自动删除,保持解析列表干净。
  • 🛠️ 自动化托管:你可以选择“拿到证书就走”,也可以一键开启“自动托管”,以后每 90 天它会自动去跑挑战并续期,你只需要坐收通知。

操作流程

  1. 输入域名:比如 yourdomain.com
  2. 选择解析商:目前支持阿里云、腾讯云。
  3. 填入 API Key:为了安全,强烈建议去云商后台开一个只具备“DNS 修改权限”的子账号(RAM/CAM),别用主账号 Key。
  4. 一键生成:后台异步调用逻辑,自动修改 DNS 并向 CA 机构发起挑战。
  5. 下载 & 托管:拿到证书后,可以顺手勾选“自动续期”,从此彻底告别证书焦虑。

最后

这个小工具目前部署在 AWS 上,还是 MVP 阶段,界面走极简风格。大家如果正好有证书快到期的,欢迎来“救急”测试,反馈 Bug:

👉 访问地址:免费 SSL 证书签发

PS: 暂时只做了基础功能。如果大家有需要增加其他解析商(如华为云、Cloudflare)支持的,可以在评论区留言,我在线蹲一个反馈!


标签: #SSL证书 #HTTPS #自动化运维 #阿里云 #腾讯云 #acme.sh #后端开发