阅读视图

发现新文章,点击刷新页面。

【节点】[SquareRoot节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

SquareRoot节点核心功能与数学原理

SquareRoot节点是Unity ShaderGraph中用于执行平方根运算的基础数学工具,其核心功能为接收输入值(标量或向量)并返回各分量的平方根结果。数学表达式为:输出值 = √输入值。该运算在图形渲染中具有明确的物理意义,常用于距离衰减、光照强度调节等场景。

技术原理解析

  • 硬件加速‌:基于HLSL的sqrt函数实现,直接调用GPU硬件优化指令,相较于组合运算(如乘方再开方)具备更高效率。
  • 多维度支持‌:支持标量(float)、二维向量(float2)至四维向量(float4)的运算。
  • 物理相关性‌:在平方反比定律(如光照衰减、引力模拟)中直接应用,简化逻辑实现流程。
  • 数学特性‌:平方根运算可将非线性关系线性化,特别适用于需要平滑过渡的渲染效果。

端口详解

  • 输入端口‌:
    • In:动态向量输入(Dynamic Vector),兼容标量及向量类型。
    • 注意事项:输入值应为非负数,否则返回NaN(Not a Number)。
    • 输入范围建议:0至正无穷,负数输入可通过Absolute节点预处理。
  • 输出端口‌:
    • Out:动态向量输出,各分量为对应输入值的平方根。
    • 输出特性:输出值始终为非负数,且输出范围与输入范围呈非线性对应关系。

SquareRoot节点在URP中的配置与使用

在URP(通用渲染管线)中配置SquareRoot节点需通过ShaderGraph编辑器实现,具体步骤如下:

创建URP兼容的ShaderGraph

  • 新建ShaderGraph‌:在Unity编辑器中右键项目资源窗口 → Create → Shader → URP Shader Graph。
  • 选择URP模板‌:确保使用URP渲染管线模板(需提前安装URP包)。
  • 添加SquareRoot节点‌:
    • 在ShaderGraph编辑器中右键空白处。
    • 搜索并选择Math分类下的Square Root节点。
    • 或通过快捷键Space打开搜索菜单,输入"Square Root"。

节点参数设置

  • 输入类型选择‌:
    • 标量输入:连接单个浮点值(如0-1的渐变纹理)。
    • 向量输入:连接三维坐标(如UV坐标、法线向量)。
  • 输出类型处理‌:
    • 标量输出:直接连接颜色通道(如R分量)。
    • 向量输出:需通过Split节点分离分量后使用。
  • 精度设置‌:
    • 高精度模式:适用于PC和主机平台。
    • 中低精度模式:推荐移动端使用。

典型应用场景与实战案例

光照衰减优化

场景‌:点光源的平方反比衰减(物理正确模型)。

  • 计算距离平方值:Distance节点 → Power节点(指数设为2)。
  • 平方根倒数运算:Square Root节点 → Reciprocal节点。
  • 应用衰减:乘法节点连接光照强度。
  • 范围限制:使用Saturate节点确保衰减系数在0-1范围内。‌优势‌:比直接使用距离值更符合物理规律,避免光照强度突变。‌实际效果‌:实现真实的光照衰减曲线,距离光源越远,光照强度平滑降低。

纹理采样权重调整

场景‌:基于距离的纹理混合(距离越近权重越高)。

  • 计算物体与相机距离:ObjectPosition节点 → CameraPosition节点 → Distance节点。
  • 平方根转换:Square Root节点。
  • 权重映射:Remap节点(输入范围0-1,输出范围0-1)。
  • 混合纹理:Lerp节点连接基础纹理与高光纹理。
  • 边缘柔化:添加Smoothstep节点实现更自然的过渡效果。‌效果‌:实现自然过渡的纹理混合,避免硬边现象。‌扩展应用‌:可用于地形纹理混合、LOD过渡、景深效果等场景。

全息投影效果强化

场景‌:增强全息投影的流光条带效果。

  • 生成条纹纹理:UV坐标的G通道 → Square Root节点。
  • 粗细控制:连接Power节点(指数>1时变粗)。
  • 渐层处理:使用Step节点或保留原始渐层。
  • 颜色映射:乘法节点连接全息颜色。
  • 动态效果:结合Time节点实现流光动画。‌原理‌:平方根运算能保留UV坐标的渐层特性,避免生硬条纹。‌技术要点‌:通过调整平方根节点的输入范围,可以精确控制条纹的密度和分布。

高级应用:体积雾效果

场景‌:实现基于距离的雾效密度计算。

  • 计算相机到片元距离:CameraPosition节点 → Position节点 → Distance节点。
  • 平方根转换:Square Root节点(将距离非线性映射到雾密度)。
  • 密度控制:Remap节点调整雾浓度范围。
  • 颜色混合:Lerp节点混合场景颜色与雾颜色。‌技术优势‌:平方根运算使雾效在近距离变化平缓,远距离变化明显,符合真实雾效特性。

高级技巧与性能优化

精度与性能权衡

  • 精度控制‌:
    • 标量运算:使用float类型(32位浮点)。
    • 向量运算:优先使用float2/float3减少计算量。
  • 性能优化‌:
    • 避免在片元着色器中重复计算(可移至顶点着色器)。
    • 使用Precision节点指定运算精度(如highp/mediump)。
    • 移动端建议:使用mediump精度,在保证效果的同时提升性能。

与其他节点的配合

  • 与Power节点组合‌:
    • 实现开方后乘方运算(如√x²)。
    • 示例:Square Root → Power → 颜色输出
  • 与Lerp节点结合‌:
    • 创建平滑过渡效果(如基于距离的透明度渐变)。
    • 示例:Square Root → Remap → Lerp(基础色/高光色)
  • 与Noise节点配合‌:
    • 生成有机形态的效果(如云层、火焰)。
    • 示例:Noise节点 → Square Root → 颜色映射

常见问题解决方案

  • 问题现象‌:输出NaN值
    • 可能原因‌:输入为负数
    • 解决方法‌:添加Abs节点取绝对值
  • 问题现象‌:性能下降
    • 可能原因‌:过度使用向量运算
    • 解决方法‌:改用标量运算或降低精度
  • 问题现象‌:效果异常
    • 可能原因‌:节点连接错误
    • 解决方法‌:检查输入输出类型是否匹配
  • 问题现象‌:移动端闪屏
    • 可能原因‌:精度不足
    • 解决方法‌:提升精度设置或使用近似算法
  • 问题现象‌:编译错误
    • 可能原因‌:平台兼容性问题
    • 解决方法‌:检查目标平台的Shader支持级别

性能监控与调试技巧

  • 使用Frame Debugger‌:实时监控SquareRoot节点的性能消耗。
  • Shader复杂度分析‌:通过ShaderGraph的复杂度视图评估优化效果。
  • 平台差异化测试‌:在不同设备上测试平方根运算的表现一致性。

跨平台开发注意事项

在URP中开发跨平台Shader时,需注意SquareRoot节点的兼容性:

  • 移动端优化‌:
    • 避免在低端设备上使用高精度运算。
    • 启用URP的Mobile质量模式自动简化计算。
    • 考虑使用近似平方根函数替代精确计算。
  • PC端增强‌:
    • 可结合Compute Shader实现并行计算。
    • 使用HDRP模板获得更精细的数学运算支持。
  • VR/AR特殊考虑‌:
    • 需要更高的性能标准。
    • 避免在每帧中重复计算相同的平方根值。

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

du Cheatsheet

Basic Usage

Common ways to check directory and file sizes.

Command Description
du Show disk usage of the current directory and its subdirectories
du file Show disk usage of a single file
du dir1 dir2 Show disk usage for multiple paths
du -h dir Human-readable sizes (K, M, G)
du -sh dir Show only the total size of a directory
du -sh * Show the size of every item in the current directory
sudo du -sh /var Run with sudo to read root-owned paths

Size Formats

Control how sizes are printed.

Option Description
-h Human-readable, powers of 1024 (K, M, G)
-H Human-readable, powers of 1000 (SI units)
-k Display sizes in 1K blocks (default on most systems)
-m Display sizes in 1M blocks
-BG Display sizes in 1G blocks
-B SIZE Use SIZE-byte blocks, for example -BM or -B512
-b Equivalent to --apparent-size --block-size=1

Summary and Totals

Reduce noise or add a grand total row.

Command Description
du -s dir Show only the total for the given directory
du -sh dir Total in human-readable format
du -c dir1 dir2 Add a total line at the bottom
du -csh /var/log /var/lib Human-readable totals plus a combined grand total
du -a dir Include every file in the listing, not just directories

Depth Control

Limit how deep du descends into the directory tree.

Command Description
du -h --max-depth=1 dir Show only the first level of subdirectories
du -h --max-depth=2 dir Show two levels deep
du -h -d 1 dir Short form of --max-depth=1
du -h --max-depth=0 dir Show only the directory total (same as -s)

Excluding Files

Skip paths or patterns from the report.

Option Description
--exclude=PATTERN Skip files and directories matching the shell pattern
--exclude-from=FILE Read exclude patterns from a file
-x Stay on the same filesystem (skip mounted ones)

Examples:

Command Description
du -sh --exclude="*.log" /var Exclude .log files from the total
du -sh --exclude=node_modules ~/projects Skip node_modules directories
du -xsh / Total of the root filesystem only, ignoring mounts

Sorting and Top N

Combine du with sort and head to find the largest items.

Command Description
du -h dir | sort -rh Sort entries by size, largest first
du -h dir | sort -rh | head -10 List the 10 largest items
du -h --max-depth=1 / | sort -rh | head -20 Largest top-level directories under /
du -ah dir | sort -rh | head -10 Largest individual files and directories
du -sh */ | sort -rh Sort current directory’s children by size

Apparent vs Disk Usage

du reports allocated blocks by default. Use these flags to see actual byte counts.

Option Description
--apparent-size Show how many bytes the file contains, not how much it occupies on disk
-b Apparent size in bytes (shorthand for --apparent-size --block-size=1)

Examples:

Command Description
du -sh --apparent-size /var/log Apparent size of /var/log
du -sb file Exact byte count of a file

Counting and Time

Less common but useful options.

Option Description
-L Follow all symbolic links
-P Never follow symbolic links (default)
-l Count sizes many times if hard linked
--time Show last modification time of the file or directory
--time=atime Show the access time instead of modification time
-0 Use a NUL character as the line separator (for piping into xargs -0)

Related Guides

Use these references for deeper disk usage workflows.

Guide Description
du Command in Linux Full du guide with practical examples
How to Get the Size of a File or Directory Focused walkthrough for sizing files and directories
How to Check Disk Space in Linux Using df Filesystem-level disk space reporting
Find Large Files in Linux Locate the biggest files across a tree

从零开始:前端转型AI agent直到就业第五天-第十一天

前言

接近8.9年老前端了,34岁,双非普本,坐标广州,25年底被裁员,然后这三个月内也有去投简历,也有面试,有一些推进到二面然后就没有下文,不禁感叹现在的大环境实在不怎么样,而且前端在AI的冲击下也是最受影响的,除了音视频,图形化方面还能蹦跶一下,AI已经能完成80-90%的前端工作,在学历以及就业背景都不是特别强的情况下,一般的前端哪怕你技术还不错,你也很缺竞争力;在失业这三个月经历了持续学习、迷茫到看到曙光,决定要转型自学做AI agent;

大纲

  • 前一天心路历程
  • 前一天的时间分配(不限于学习,也会有运动)
  • 前一天的知识总结(前期或许较少)

心路历程

很久一段时间没有更新了,并不是断更了,而是慢慢地进入了状态,最近都是每天早上起来学习直到晚上

时间分配

一般早上8点起来学习到中午12点休息2-3个小时继续学习到晚上8点,然后开始整理文档发到博客

知识总结

不知不觉间,已经整理了很多相关的文档对于这个赛道的知识体系有一个粗略的认知并且有了一定的基础

image.png

今天发一下今天学到的知识体系发一下吧:

RAG检索增强生成

第一章:RAG 思想与核心价值

1.1 什么是 RAG?

通俗理解

想象一下:

你有一个非常聪明但有点健忘的朋友(大语言模型,LLM)。他知道很多常识,但如果你问他:“我们上周三开会时说了什么?” 他就傻眼了,因为他没有那天的记忆。

RAG 就是给这个朋友配了一个笔记本。每次你问问题,他先快速翻笔记本(检索),找到相关记录,然后结合自己的理解来回答你(生成)。

  • 没有 RAG:LLM 只靠训练时记住的知识回答 → 容易“已读乱回”(幻觉)或“已读不回”(知识陈旧)
  • 有了 RAG:LLM 先查你给的知识库,再基于这些知识回答 → 答案更准确、可溯源
官方定义

RAG(Retrieval-Augmented Generation,检索增强生成) 是一种将信息检索大语言模型生成能力相结合的技术架构。

核心公式:RAG = 检索(Retrieval) + 增强(Augmented) + 生成(Generation)

环节 作用
检索 从知识库中找到与问题相关的信息片段
增强 把这些片段作为“上下文”注入到提示词中
生成 LLM 基于增强后的提示词生成最终答案
RAG vs 微调(Fine-tuning)
对比维度 RAG 微调
知识更新 只需更新知识库,无需重新训练 需要重新训练模型
可解释性 答案可追溯到原始文档 难以追溯知识来源
实现成本 低,无需 GPU 训练 高,需要训练算力
实时性 秒级生效 小时/天级
适用场景 知识频繁更新、私有文档问答 改变模型风格、行为或学习特定格式

💡 一句话建议:想让模型知道新事实 → 用 RAG;想改变模型行为方式 → 考虑微调。


1.2 RAG 能解决什么问题?

问题 说明 RAG 如何解决
知识截止日期 GPT-4 知识截止于 2023 年 10 月 注入最新的文档(如今天的新闻)
模型幻觉 LLM 会编造不存在的“事实” 强制基于检索到的上下文回答
私有领域知识 公司内部文档、产品手册、法律条文 将这些文档作为知识库
动态更新 知识每天变化(如股价、政策) 只需更新向量库,秒级生效
答案可溯源 用户想知道“你从哪里知道的” 返回答案时可附带来源文档

1.3 RAG 工作流程全景图

RAG 分为两大阶段

阶段一:索引阶段(Indexing)—— 离线准备知识库
原始文档 → 文档加载 → 文本拆分 → 文本块 → 向量化 → 向量数据库
   │           │          │         │        │         │
 PDF/Word     读取     切分成块   小片段   转成向量   存储检索

这个阶段不需要用户等待,可以在后台定期执行(如每晚更新一次)。

阶段二:检索与生成阶段(Retrieval & Generation)—— 在线回答问题
用户问题 → 向量化 → 问题向量 → 向量数据库相似度搜索 → Top-K 相关文本块
                                                              ↓
最终答案 ← 大语言模型 ← 构建 Prompt(上下文 + 问题) ← ────────┘
一个完整的例子

假设你上传了一份《2024年公司休假政策》文档:

步骤 阶段 发生了什么
1 索引 文档被切分成块 → 向量化 → 存入向量库
2 检索 你问:“春节放假几天?” → 问题被向量化
3 检索 向量库找到最相关的文本块(含“春节假期7天”)
4 生成 Prompt = “根据上下文回答:春节放假几天? 上下文:春节假期7天...”
5 生成 LLM 回答:“根据公司政策,春节放假7天。”

第一章小结

核心概念 一句话总结
RAG 先查资料,再回答问题,让 LLM 有据可依
索引阶段 离线准备知识库(文档→向量库)
检索+生成阶段 在线回答问题(问题→检索→生成)
RAG vs 微调 RAG 给知识,微调改能力

第二章:RAG 核心原理(纯概念,无代码)

本章只讲原理,不涉及任何代码或框架。所有 LangChain 实现放在第四章。

2.1 索引阶段原理

2.1.1 文档加载

目标:将各种格式的原始文档读取为程序可处理的纯文本。

挑战:不同格式有不同复杂度

格式 挑战 原理说明
PDF 表格、图片、多列布局 需要解析器提取文字流
Word 复杂格式、嵌入对象 需要解压并提取文本
Markdown 标题层级需保留 标题可作为结构信息
HTML 标签噪声 需去除标签,保留正文
纯文本 最简单 直接读取
2.1.2 文本拆分(Chunking)

为什么需要拆分?

  1. LLM 上下文窗口限制:模型一次能处理的文本长度有限
  2. 检索精度:小块更容易精准匹配问题,大块会引入噪声
  3. 成本控制:只发送相关片段,节省 token 费用

核心概念

概念 含义 示例
chunk_size 单个文本块的最大长度 500 字符 或 200 tokens
chunk_overlap 相邻块之间的重叠长度 50 字符,保留上下文连续性
separators 优先切割的位置 段落 > 句子 > 词语 > 字符

重叠的作用

文档: [A段开头...中间部分...B段结尾]
                    ↓
块1: [A段开头...中间部分]
块2: [中间部分...B段结尾]  ← 重叠部分防止信息被切断
2.1.3 文本向量化(Embedding)

什么是向量化?

将文本转换为固定维度的浮点数数组(向量),语义相似的文本在向量空间中距离更近

"苹果很好吃" → [0.12, -0.34, 0.56, ..., 0.78]  (1536维)
"水果很美味" → [0.11, -0.33, 0.55, ..., 0.79]  (距离很近,语义相似)

"汽车很快"   → [-0.45, 0.23, -0.67, ..., 0.12]  (距离很远,语义不同)

关键原则:索引阶段和检索阶段必须使用同一个 Embedding 模型,否则向量空间不匹配,无法正确比较。

2.1.4 向量数据库存储

存储的内容结构:

┌─────────────────────────────────────────────┐
│              向量数据库中的一条记录           │
├─────────────────────────────────────────────┤
│  向量:[0.12, -0.34, 0.56, ..., 0.78]       │
│  原始文本:"春节假期共7天"                    │
│  元数据:{"source": "holiday.pdf", "page": 3}│
└─────────────────────────────────────────────┘

2.2 检索与生成阶段原理

2.2.1 问题向量化

将用户问题用与索引阶段相同的 Embedding 模型转换为向量。

2.2.2 相似度搜索

常用相似度算法

算法 直观理解 公式
余弦相似度 关注方向是否一致(最常用) `cos(θ) = (A·B)/( A B )`
欧氏距离 关注绝对距离远近 d = √Σ(Ai-Bi)²
点积 向量已归一化时等价于余弦 A·B

Top-K 检索:返回与问题向量最相似的 K 个文本块。

2.2.3 构建 Prompt

核心思想:将检索到的文本块作为“上下文”注入到提示词中。

标准 RAG Prompt 模板结构

你是一个基于以下上下文回答问题的助手。

<上下文>
{这里放检索到的相关文本块}
</上下文>

问题:{用户的问题}

请基于以上上下文回答。如果上下文中没有相关信息,请说"我不知道"。
2.2.4 LLM 生成

大语言模型接收包含“上下文+问题”的 Prompt,基于上下文生成答案,而不是依赖自己的训练记忆。


第二章小结

概念 一句话解释
文本拆分 把长文档切成小块,便于检索
chunk_size 每块多大
chunk_overlap 块之间重叠多少
向量化 把文字转成数字数组
相似度搜索 找最接近问题向量的文本块
Top-K 返回最相似的 K 个块
Prompt 把“上下文+问题”打包发给 LLM

第三章:向量数据库选型

3.1 为什么需要向量数据库?

传统数据库(如 MySQL)无法高效进行向量相似度搜索

能力 传统数据库 向量数据库
精确匹配 ✅ 快 ❌ 不支持
模糊搜索 ⚠️ 慢 ❌ 不支持
向量相似度 ❌ 不支持 ✅ 快
标量过滤 ✅ 支持 ✅ 支持(多数)

向量数据库专为向量搜索设计,提供:

  • 高效索引:HNSW、IVF 等算法实现毫秒级搜索
  • 相似度计算:内置余弦、欧氏距离等
  • 混合搜索:向量 + 标量过滤

3.2 常用向量数据库对比

数据库 类型 性能 易用性 扩展性 最佳场景
Chroma 嵌入式 中等 ⭐⭐⭐⭐⭐ 学习原型、小项目
FAISS ⭐⭐⭐ 本地研究、无需持久化
Pgvector PostgreSQL扩展 中高 ⭐⭐⭐⭐ 已有 PostgreSQL 栈
Milvus 云原生 极高 ⭐⭐ 极高 十亿级向量生产环境
Redis 内存数据库 极高 ⭐⭐⭐⭐ 超低延迟场景
Elasticsearch 搜索引擎 ⭐⭐⭐ 需要混合搜索
各数据库详解

Chroma

  • 轻量级,纯 Python,API 极其简单
  • 数据持久化到本地磁盘
  • 适合:学习 RAG、原型验证、小规模应用

FAISS

  • Facebook 开源,C++ 核心,性能强悍
  • 本质是库而非完整数据库(无持久化,需自己管理)
  • 适合:学术研究、本地实验、对性能要求高但不需分布式

Pgvector

  • PostgreSQL 官方扩展,SQL 语法操作向量
  • 复用现有 PG 基础设施(备份、高可用、权限)
  • 适合:团队已有 PostgreSQL,不想引入新组件

Milvus

  • 云原生架构,支持十亿级向量
  • 功能最全:混合搜索、动态 schema、多副本
  • 适合:大规模生产系统、需要分布式扩展

Redis

  • 内存级速度,毫秒级响应
  • 支持向量搜索作为辅助功能
  • 适合:超低延迟场景、已有 Redis 基础设施

Elasticsearch

  • 老牌搜索引擎,现支持向量
  • 最大优势:关键词搜索 + 向量搜索混合
  • 适合:需要同时支持精确关键词匹配和语义匹配

3.3 选型决策树

开始
  │
  ├─ 只是学习/原型 → Chroma
  │
  ├─ 已有 PostgreSQL → Pgvector
  │
  ├─ 十亿级向量 / 云原生 → Milvus
  │
  ├─ 需要超低延迟(<10ms)→ Redis
  │
  ├─ 需要关键词+向量混合 → Elasticsearch
  │
  └─ 本地研究/高性能 → FAISS

第四章:LangChain 实战(精简版)

本章只讲核心常用代码,次要内容简要带过。

4.1 环境准备

pip install langchain langchain-community chromadb openai tiktoken
# 按需安装:unstructured pypdf docx2txt jq redis dashscope

4.2 核心组件速览

组件 作用 常用类
文档加载器 读取各种格式文档 TextLoader, PyPDFLoader, CSVLoader, Docx2txtLoader, JSONLoader
文本分割器 切分长文档 RecursiveCharacterTextSplitter(首选)
Embedding模型 文本向量化 OpenAIEmbeddings, HuggingFaceEmbeddings, DashScopeEmbeddings
向量数据库 存储与检索 Chroma(学习), Redis(生产), FAISS(本地)
检索器 查询相关文档 as_retriever(k=4)
LLM 生成答案 ChatOpenAI, init_chat_model(阿里千问)
Prompt模板 组装提示词 PromptTemplate, ChatPromptTemplate

4.3 文档加载器(常用示例)

# 纯文本
from langchain_community.document_loaders import TextLoader
loader = TextLoader("file.txt", encoding="utf-8")
docs = loader.load()

# PDF
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("file.pdf", extraction_mode="plain")
docs = loader.load()

# Word
from langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader("file.docx")
docs = loader.load()

# CSV
from langchain_community.document_loaders.csv_loader import CSVLoader
loader = CSVLoader(file_path="file.csv")
docs = loader.load()

# JSON
from langchain_community.document_loaders import JSONLoader
loader = JSONLoader(file_path="file.json", jq_schema=".", text_content=False)
docs = loader.load()

其他加载器(Markdown、HTML、目录批量等)用法类似,按需查阅文档。

4.4 文本分割器

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # 每块最大字符数
    chunk_overlap=50,    # 块间重叠
)
chunks = splitter.split_documents(docs)

4.5 Embedding 模型

# OpenAI
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# HuggingFace 本地(中文推荐)
from langchain_community.embeddings import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh")

# 阿里千问
from langchain_community.embeddings import DashScopeEmbeddings
embeddings = DashScopeEmbeddings(model="text-embedding-v3", dashscope_api_key=api_key)

4.6 向量数据库

# Chroma(学习推荐)
from langchain_community.vectorstores import Chroma
vector_store = Chroma.from_documents(chunks, embeddings, persist_directory="./db")
vector_store.persist()

# Redis(生产推荐)
from langchain_community.vectorstores import Redis
vector_store = Redis.from_documents(docs, embeddings, redis_url="redis://localhost:6379", index_name="my_index")

# FAISS(本地快速)
from langchain_community.vectorstores import FAISS
vector_store = FAISS.from_documents(chunks, embeddings)
vector_store.save_local("./faiss_index")

4.7 检索器

retriever = vector_store.as_retriever(search_kwargs={"k": 4})  # 返回 Top-4

4.8 LLM 模型

# OpenAI
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

# 阿里千问
from langchain.chat_models import init_chat_model
llm = init_chat_model(model="qwen-plus", model_provider="openai", api_key=api_key, base_url="https://dashscope.aliyuncs.com/compatible-mode/v1")

4.9 Prompt 模板

from langchain_core.prompts import PromptTemplate

template = """基于以下上下文回答问题:
上下文:{context}
问题:{question}"""
prompt = PromptTemplate(template=template, input_variables=["context", "question"])

4.10 完整 RAG Chain

from langchain_core.runnables import RunnablePassthrough

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
)

result = rag_chain.invoke("你的问题")
print(result.content)

4.11 完整问答实例(阿里千问 + Redis)

# complete_rag_example.py
import os
from langchain.chat_models import init_chat_model
from langchain_community.document_loaders import Docx2txtLoader
from langchain_core.prompts import PromptTemplate
from langchain_classic.text_splitter import CharacterTextSplitter
from langchain_core.runnables import RunnablePassthrough
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Redis

# 1. 初始化 LLM
llm = init_chat_model(
    model="qwen-plus",
    model_provider="openai",
    api_key=os.getenv("aliQwen-api"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)

# 2. Prompt 模板
prompt_template = """
请使用以下提供的文本内容来回答问题。仅使用提供的文本信息,
如果文本中没有相关信息,请回答"抱歉,提供的文本中没有这个信息"。

文本内容:{context}
问题:{question}
回答:
"""
prompt = PromptTemplate(template=prompt_template, input_variables=["context", "question"])

# 3. Embedding
embeddings = DashScopeEmbeddings(model="text-embedding-v3", dashscope_api_key=os.getenv("aliQwen-api"))

# 4. 加载文档
loader = Docx2txtLoader("alibaba-java.docx")
documents = loader.load()

# 5. 分割文档
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
texts = text_splitter.split_documents(documents)

# 6. 创建 Redis 向量库
vector_store = Redis.from_documents(
    documents=documents,
    embedding=embeddings,
    redis_url="redis://localhost:6379",
    index_name="my_index",
)

# 7. 检索器
retriever = vector_store.as_retriever(search_kwargs={"k": 2})

# 8. RAG Chain
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
)

# 9. 提问
result = rag_chain.invoke("00000和A0001分别是什么意思")
print(result.content)

第五章:参数调优与最佳实践

5.1 核心参数调优指南

chunk_size 选择
文档类型 推荐值 原因
产品问答 200-300 字符 每个问答短小精悍
技术文档 500-800 字符 段落通常较长
法律条文 300-500 字符 条款需保持独立
长篇文章 1000-1500 字符 保持上下文连贯
chunk_overlap 设置
chunk_overlap = chunk_size × (10% ~ 20%)
Top-K 选择
K 值 适用场景 优点 缺点
3 答案集中在少数段落 精准 可能遗漏信息
5 通用推荐 平衡 -
10 需要广泛上下文 信息全面 噪声增多,成本增加

5.2 进阶优化技术(简介)

技术 一句话说明
多查询检索 将问题改写成多个角度,分别检索后合并
父文档检索 存小块(精准匹配),返回大块(完整上下文)
自查询检索 从问题中提取语义条件 + 元数据过滤条件
重排序 检索更多结果,用更强模型重新排序取 Top-K

5.3 常见问题排查

问题 可能原因 解决方案
答案不相关 chunk 太大含噪声 减小 chunk_size
丢失关键信息 chunk 太小切断上下文 增大 chunk_sizeoverlap
检索不到 问题表述与文档不匹配 使用多查询检索
回答“不知道”但有文档 Embedding 模型不适合中文 BAAI/bge-large-zh
速度慢 向量库太大 添加索引、使用 GPU
成本高 Top-K 或 chunk 太大 减小 K 和 chunk_size

5.4 推荐起步配置

splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
retriever = vector_store.as_retriever(search_kwargs={"k": 4})

目标

成为AI agent工程师并且就业

帮助

需要大家的关注跟点赞,你们的关注点赞就是对我最大的鼓励,或许以后待以后我技术成熟时,你们中间的大佬还可以捞一捞我,感谢

重磅预告|OpenTiny 亮相 QCon 北京,共话生成式 UI 最新技术思考

QCon 北京 2026 重磅来袭!🚀

OpenTiny 团队受邀亮相 QCon 全球软件开发大会,带来生成式 UI 最新技术实践分享。

在 AI 重构前端开发的浪潮下,界面开发正从 “手写组件” 走向 “自然语言生成”。但模型生成的界面往往难以落地:交互不完整、业务逻辑缺失、无法对接真实后端与工具生态……

本次分享,OpenTiny 团队林瑞虹老师将聚焦 GenUI SDK 这套面向生成式 UI 的前端开发工具,介绍了生成式 UI 的原理以及在业务场景落地诉求下对能力的改造与扩展,讨论了生成式 UI 性能指标以及应用场景的局限性。并对业界多个生成式 UI 产品协议进行对比,探讨了协议标准化的不同观点。

你将听到这些硬核内容

  • 生成式 UI 落地的真实痛点与解决方案探讨
  • GenUI SDK 核心原理设计:如何保证界面可控、可扩展、可维护
  • 业界多协议对比及标准化方向思考

无论你是前端开发者、架构师,还是关注 AI + 前端 的技术负责人,都能在本次分享中清晰理解生成式 UI 的技术价值、实现原理、落地场景与现实局限,为后续技术选型提供扎实参考与决策依据。

活动信息

  • 会议: QCon 北京 2026 全球软件开发大会
  • 专题: 下一代交互架构:LUI 与 GUI 的融合
  • 主题: 生成式 UI :AI 交互新模式探索
  • 讲师: OpenTiny 团队林瑞虹老师

欢迎现场交流,一起探索前端开发的下一代范式。关注我们,后续将分享完整演讲干货。

图片

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
GenUI SDK 代码仓库:github.com/opentiny/ge… (欢迎star ⭐)
关于我们:opentiny.design/opentiny-de…

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

AI全栈入门指南:NestJs 中的 DTO 和数据校验

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。

技术上可以。@Body()@Query()@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。

真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。

这就是 DTO 要解决的问题。

DTOData Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:

  • 这次请求允许出现哪些字段
  • 每个字段期望的类型是什么
  • 哪些是必填
  • 除类型以外还要满足哪些约束

拿"创建用户"来说,若没有契约,你很容易遇到:

  • name 是空字符串
  • email 根本不像邮箱
  • age 传成了 "abc"
  • 客户端悄悄带上 role: "admin"

脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。

所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。

下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:

import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";

/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(0)
  age: number;
}

这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。

class-validatorclass-transformer

NestJS 里,DTO 通常和两个库成对出现:

  • class-validator 管规则,字段对不对、满不满足约束
  • class-transformer 管形态,把普通对象转成类实例,并在需要时做类型转换

一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。

查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()parseInt 打交道。

下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()@Min(1) 收紧范围。

import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";

/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
  @IsOptional()
  @IsString()
  keyword?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。

全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。

依赖若尚未安装,在项目根目录执行:

pnpm add class-validator class-transformer

装好后,DTO 上的装饰器才有运行时意义。

ValidationPipe 的用法

光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe

把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。

默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。

最常见的做法是在 main.ts 里全局挂上管道:

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。

import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    // 能执行到这里时,body 已通过校验并按 DTO 做过转换
    return body;
  }
}

不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。

如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:

import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post("draft")
  @UsePipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: false,
    }),
  )
  saveDraft(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }
}

对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。

白名单、转换与多余字段

ValidationPipe 的价值不止于报错。whitelistforbidNonWhitelistedtransform 三个开关配合起来,可以把入口擦得很干净。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist

whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。

DTO 只有 nameemail,客户端却带了 roleisAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。

forbidNonWhitelisted

forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。

公开 API、对接第三方、强契约场景更适合打开它。

transform

transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。

例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()

实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。

20260328102554

参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。

嵌套对象与数组

请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。

常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()@ArrayMinSize() 等与集合相关的装饰器。

import { Type } from "class-transformer";
import {
  IsArray,
  IsString,
  MinLength,
  ValidateNested,
} from "class-validator";

export class AddressDto {
  @IsString()
  @MinLength(1)
  city: string;
}

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsArray()
  @IsString({ each: true })
  tags: string[];
}

嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。

从已有 DTO 派生

更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}

安装依赖:

pnpm add @nestjs/mapped-types

还有 PickTypeOmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。

DTOEntityVO 不要混用

后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。

DTOEntityVO 都可以是一组字段,但站位不同:

  • DTO 对准接口进出的契约
  • Entity 对准持久化与领域状态
  • VO 对准对外展示或某次响应的裁剪结果

同一张用户表在三层里的切片往往不一样。

UserEntity 里可能有 idnameemailpasswordHashcreatedAtupdatedAt。创建用户的 CreateUserDto 只要 nameemailpassword。返回前端的 UserProfileVo 可能只给 idnameemail。看起来都在描述用户,语义并不相同。

混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。

/** 创建接口入参 */
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
  id: string;
  name: string;
  email: string;
}

即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。

小结

这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:

接口参数不能默认可信。

DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。

若下面这些已经变成你的默认思路,这一章就到位了:

  • 控制器拿到的外部数据不要裸用
  • 入参用 DTO 声明,并配合管道校验与转换
  • 嵌套与数组要有对应的嵌套 DTO 与集合装饰器
  • 需要时用 PartialType 等工具派生,避免复制粘贴
  • DTOEntityVO 各司其职,不因字段相似就混成一类

下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。

AI 全栈指南:NestJs 中的 Service Provider 和 Module

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。

这段逻辑默认放在 Service 里。

先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:

  • 数据怎么查、怎么写
  • 规则怎么判定
  • 结果怎么拼装
  • 同一套逻辑别处还要不要复用

拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。

下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:

import { Injectable } from "@nestjs/common";

/** 内存里的用户结构,仅作示意 */
interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: "1", name: "汤姆" },
    { id: "2", name: "杰瑞" },
  ];

  /** 返回全部用户 */
  findAll(): User[] {
    return this.users;
  }

  /** 按主键查找,没有则 undefined */
  findById(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }
}

数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。

Service 带来的直接好处主要是两条:

  • 控制器变薄,一层里不塞满所有事
  • 业务逻辑方便复用、写测试、以后改实现

习惯可以记得很短:控制器对齐请求,Service 扛起业务。

Provider 的本质

不少人初学时会把 ProviderService 混着说,其实分清也不难:Service 是很常见的一种 ProviderProvider 这个词包住的是所有"可注入实现"。

凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:

  • 业务服务,例如 UsersService
  • 仓储或数据访问类,例如 UsersRepository
  • 横切能力,例如 MailService
  • 配置对象、工厂返回值、自定义 token 绑定的实例,也都算

框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:

  • 要不要由容器负责实例化
  • 能不能被别人注入
  • 生命周期怎么配合作用域

写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。

下面两个类分工不同,在容器眼里却一视同仁,都是 Provider

import { Injectable } from "@nestjs/common";

@Injectable()
export class UsersService {
  findAll(): string[] {
    return ["汤姆", "杰瑞"];
  }
}

@Injectable()
export class MailService {
  sendWelcomeMail(email: string): string {
    return `已向 ${email} 发送欢迎邮件(示意)`;
  }
}

命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。

记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。

Module 是什么

Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。

NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。

用户、订单、认证可以各自落在 UsersModuleOrdersModuleAuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。

最小模块长这样:

import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 用户领域:对外入口 + 可注入服务 */
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider

从结构上看,可以先扫一眼下面这张图。

20260328102242

节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。

别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。

imports 等四个字段各管什么

第一次看 @Module() 里的配置,最容易缠在一起的是 importsproviderscontrollersexports。拆开看就顺了。

下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:

import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

四个键可以先记成功能分工:

  • imports 本模块依赖哪些别的模块已经 exports 出来的能力
  • providers 本模块自己要注册、仅供内部(默认可注入范围)使用的 Provider
  • controllers 本模块声明哪些 HTTP 入口
  • exports 本模块对外放行哪些 Provider,供在别处 imports 了本模块的代码继续注入

最常绊脚的一对是 providersexports

  • providers 是"家里有哪些实现"
  • exports 是"门口挂牌、准许邻居借用的有哪些"

留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports

这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。

分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。

为什么业务逻辑不能全写在 Controller

新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。

项目一大,这样最容易长胖的是控制器。

下面这个例子能跑,但已经在兼职干 Service 的活:

import { Body, Controller, Post } from "@nestjs/common";

/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    const exists = body.email === "tom@example.com";

    if (exists) {
      return { message: "该邮箱已存在" };
    }

    const user = {
      id: Date.now().toString(),
      name: body.name,
      email: body.email,
      status: "正常",
    };

    return { message: `已创建用户:${user.name}` };
  }
}

收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。

把规则挪进 Service,控制器只做转发,形态会干净很多:

import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    // 业务规则交给服务层
    return this.usersService.create(body);
  }
}

改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。

收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。

为什么 ModuleNestJS 里最核心的那一层边界

Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。

维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。

NestJSModule 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。

边界划清楚以后,好处很实在:

  • 用户、订单、支付、认证各自有落脚模块
  • 依赖不容易随便渗透到别的模块内部
  • 拆分、复用、补测试都更顺手
  • 新人找功能时有目录感
  • 大重构可以按模块切块推进

反过来,模块若只是分文件夹,ServiceController 再多也可能是一盘散沙。

所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。

顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。

这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。

小结

这篇的重点不是多记几个词,而是把三条线拧到一根绳上:

  • Service 承接大部分业务
  • Provider 是容器能注入的那类东西的统称
  • Module 划边界、装箱、再决定对外露什么

判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。

下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。

Harness Engineering:为什么你用 AI 越用越累?

Harness Engineering:驾驭 AI Agent 的工程学

Harness Engineering 封面图

"任何时候当你发现一个 agent 犯了一个错误,你就花时间工程化地解决它,使得这个 agent 再也不会犯那个错误。" — Mitchell Hashimoto(Terraform / Ghostty 作者,Harness Engineering 早期推广者之一)


换了更好的模型,只提升了 0.7%

LangChain 用一次实验把一件事说清楚了。

他们拿同一个模型参加 Terminal Bench 2.0 基准测试:默认设置跑出 52.8 分,排第 30 名;什么模型参数都没改,只调整了 agent 的运行环境——文档结构、验证回路、追踪系统——分数跳到 66.5,排名升到第 5 名,提升 26%

对比组:换成更好的模型,提升 0.7%

这组数字在工程师圈子里流传了很久。不是因为好看,而是因为它指向一个让人不舒服的问题:如果你的 AI 工程精力都集中在"换更好的模型"上,你可能把 99% 的注意力放在了那 0.7% 的空间里。

这就是 Harness Engineering 要解决的问题。


三次范式跃迁

AI 工程已经走过了三代。每一代工程师的焦点都不一样:

三次范式跃迁图

第一代:Prompt Engineering(2022-2024),问题是"怎么跟模型说话"。Few-shot、Chain-of-Thought、角色设定——工程师花大量时间打磨措辞,因为同一个问题换种说法,结果可能天差地别。

第二代:Context Engineering(2025),瓶颈转移了。影响质量的不再是怎么说,而是给它看什么。私域知识、历史对话、动态状态——怎么把正确的信息在正确的时机送进上下文窗口,成了核心工程问题。

第三代:Harness Engineering(2026 起),瓶颈再次转移。问题不再只是"给 agent 看什么",而是"在什么样的系统里让它工作"——约束、工具、反馈机制、验证回路,以及在 agent 出错时让整个系统能自我修正的能力。

Prompt Engineering  →  优化说话方式
Context Engineering →  优化信息质量  
Harness Engineering →  优化运行系统

OpenAI 在内部实验报告里直接说了:

"早期进展比预期慢,不是因为 Codex 能力不足,而是因为环境设计不充分。Agent 缺少可靠推进目标所需的工具、抽象和内部结构。"


什么是 Harness Engineering?

"Harness" 来自马术——那套套在马身上、用于控制和驾驭的整套装具:笼头、缰绳、胸带、肚带。它不是让你骑马,而是让马在你设计的系统里知道该往哪走、什么时候停、哪里绝对不能踏入。

在 AI agent 的语境里,harness 指的是模型本身以外的一切

AI Agent = 模型 + Harness

包括上下文配置、工具集、约束规则、反馈循环、子 agent 架构——所有让模型在你的具体问题域里可靠工作的工程设施。

这个概念由实践者 Viv 首创,Mitchell Hashimoto 是最早公开使用并推广它的人之一。他给出的定义极其简洁:每当发现一个 agent 犯了错,就把这个错变成物理上不可能再发生的事。不是修 prompt,不是换模型——是工程化地消灭这类失败。

Harness Engineering 不是一个框架,不是一个库,是一套工程实践哲学


这些都不是 PPT 数字

在讨论怎么做之前,先看几个已经在生产里跑的案例:

Peter Steinberger(OpenClaw 作者):一个人,一个月 6600+ commits,同时运行 5-10 个 agent,发布的是自己没有逐行读过的代码。

OpenAI 内部团队:3 名工程师,5 个月,用 Codex 建造了一个百万行的内部产品,零行手写代码(by design)。平均每人每天 3.5 个 PR,吞吐量随团队增长持续提升。

Stripe Minions:内部 coding agent,每周合并超过 1000 个 PR。工程师在 Slack 发任务,agent 写代码、跑 CI、开 PR,全程无需人工干预。

8Lee(YEN 作者):一条命令 $zip,编译、签名、公证一个覆盖 30 种语言的 macOS 桌面应用,15 分钟完成,近 1000 次发布,零次出错。

Anthropic 内部实验:16 个 Claude 实例并行写 C 语言编译器,历经 2000 个 session、两周时间、约两万美元 API 费用,产出了 10 万行编译器代码——能编译出可以正常启动 Linux 的程序。

以上都不是 demo,都是真实规模的生产系统。让它们得以运转的,是各自精心设计的 harness。


越快越慢:AI 的速度陷阱

这里有一组让人不舒服的数字,来自 Harness 的《2026 DevOps 现代化报告》:

在每天频繁使用 AI 工具的重度用户里:

  • 69% 表示 AI 生成的代码会频繁引发部署问题
  • 事故恢复平均时长 7.6 小时,比轻度用户还要长
  • 47% 反映下游的手工工作——QA、验证、修复——比以前更繁重

DORA 的数据从另一个角度印证了同样的问题:AI 让个人生产率提升 19%,但组织吞吐量只提升了 3%,交付稳定性甚至下降了 9%

写代码的速度提升了,但交付系统被暴露了。就像把火车开得更快,但铁路还是按原来的时速设计的——摩擦越来越大,随时有翻车风险。

加速代码生成,不等于加速软件交付。 Harness 是连接两者的桥梁。


模型偷懒:一个比"上下文太长"更深的问题

在讲具体的工程实践之前,有一个反直觉的研究结论值得单独讲清楚,因为它影响了 harness 设计的底层逻辑。

大家都知道上下文太长会影响模型表现。但通常的解释是"模型被搞混了"。Yandex 研究员 Rodionov 的实验推翻了这个假设:

模型不是被搞混了,它是选择了少思考。

他向 Qwen 的上下文里注入 128 个随机 token 的噪音——仅仅 128 个 token。结果:

  • 准确率从 74.5% 降到 67.8%
  • 推理 token 数量从 28,771 降到 16,415,减少了 43%
  • 推理深度下降 18%

更反直觉的:推理能力越强的模型,退化越严重

噪音触发的不是混乱,是懒惰。模型看到上下文质量下降,会主动降低思考投入。

Anthropic 的情感研究团队在模型内部找到了这个现象的神经层面解释:他们发现了一个"desperate(绝望)"情感向量——当它激活时,模型倾向于走捷径、寻找替代路径逃避任务。对应地存在一个"calm(平静)"向量,能抑制这种倾向。

这对 harness 设计有直接影响:上下文管理的核心不只是过滤信息,而是防止信号质量下降触发模型的懒惰机制。你需要保证进入 agent 的每一条信息都是高信噪比的。


Harness Engineering 的六个核心组件

Harness Engineering 六个核心组件图

1. AGENTS.md:写给 AI 的操作手册

大多数项目有 README,但 README 是写给人类的。AGENTS.md(或 CLAUDE.md)是写给 AI 的——每次 agent 启动都会读这个文件。

AGENTS.md 的本质不是描述项目,而是记录历史失败。

Hashimoto 在他的终端模拟器 Ghostty 里观察到:这个文件里的每一行,都对应一次真实发生过的 agent 失败。它不是他预先设计的规则,是他从真实错误里提炼出来的防火墙。

# AGENTS.md(节选自实战案例)

## 代码签名规则
- **绝对不要**使用 `codesign --deep`,它会生成无效的嵌套签名
- 正确的签名顺序是从内到外:先签最内层二进制,最后签外层 app bundle

## Git 操作规则  
- **绝对不要**使用 `git add -A`,除非你刚刚运行了 `git status`
- **绝对不要** force push,除非被明确要求

## 测试规则
- **绝对不要**写只测试 mock 行为的测试
- **绝对不要**因为测试失败就删除测试

写法有数据支撑。 Vlad Temian 做了 150+ 次实验测量 Claude 对指令的遵从率:

写法 遵从率
简洁强硬("NEVER do X") 94.8%
详细解释("Because of reason Y, you should consider not doing X") 86.6%

ETH 苏黎世的研究也发现,大多数 AGENTS.md 文件要么没用,要么有害——主要原因是太长、太模糊、包含条件性规则。让 AI 帮你生成这个文件,实际上会降低性能,还额外消耗 20% 以上的 token。

几条实践原则

  • 总长度控制在 300 行以内(HumanLayer 自己的在 60 行以下)
  • 每条规则一句话,不加解释,不加"因为"
  • 只放普遍适用的规则,条件性规则用技能(Skills)处理
  • 手工写,每次 agent 犯错后更新

2. Hooks:把"告知"变成"拦截"

这是 Harness Engineering 里最反直觉但最有效的洞见:

强制执行远比告知可靠。

写在 AGENTS.md 里的规则,agent 可能在某个复杂的上下文里忽略掉。在命令执行之前拦截它的脚本,agent 物理上无法绕过。

#!/bin/bash
# guard-codesign-deep.sh

if echo "$TOOL_INPUT" | grep -q '\-\-deep'; then
  echo "BLOCKED: codesign --deep 会产生无效的嵌套签名。"
  echo "正确做法:从内到外签名,先签最内层二进制,最后签外层 app。"
  exit 1
fi

这 5 行脚本比任何 prompt 都可靠。不管上下文有多长,不管 prompt 多复杂,agent 永远不会成功执行 codesign --deep

8Lee 为 YEN 项目定义了 5 个 hook,覆盖他认为最危险的失败场景:

Hook 防护目标
block-rm.sh 防止 rm -rf 灾难性删除
guard-force-push.sh 保护 commit 历史
guard-codesign-deep.sh 强制正确的签名顺序
guard-vendor.sh 防止直接修改第三方库
guard-sensitive-file.sh 防止 .env.pem.key 泄露

总投入:约 2 小时。收益:近千次发布零安全事故。


3. 架构即护栏:越相信 AI,越需要给它设限

OpenAI 内部团队在构建百万行产品时得出了一个反直觉的结论:

"Agent 在有严格边界和可预测结构的环境里效率最高。所以我们围绕极度刚性的架构模型构建应用。每个业务域被分成固定的几层,依赖方向经过严格验证,可接受的边集非常有限。这些约束通过自定义 linter(由 Codex 生成)和结构测试机械地强制执行。"

Thoughtworks 的 Birgitta Böckeler 把这个原则概括得很清晰:

提高对 AI 生成代码的信任,需要缩小选择空间,而不是扩大自由度。

  • 架构灵活 → agent 每个决策点都有太多可能性 → 行为不可预测
  • 架构刚性 → agent 每个决策点只有少数合法选项 → 行为可靠

这里有一个工程上的精妙设计:OpenAI 团队的 linter 报错同时包含修复指南

❌ ArchViolation: service-layer 不能直接依赖 repository-layer
   解决方案:通过 domain-service 接口访问,参见 docs/architecture.md#dependency-rules

工具不只在拦截,它在教 agent 下一步该怎么做。


4. Sub-Agent 架构:Context 防火墙与并发控制

Context Rot(上下文腐化)是真实的,而且比你想象的更深

Chroma 测试了 18 个模型,发现随着 context window 长度增加,模型在任务上的表现单调下降——即使是简单任务。当上下文里有低语义相关的干扰项时,下降更陡。

这还有一个更隐蔽的问题:Context Anxiety(上下文焦虑)——部分模型在感知到 context window 快满时,会主动提前收尾、跳过尚未完成的步骤。Agent 不是因为任务完成了才停,而是因为它"感觉快撑不住了"就停了。

结合前文的 Rodionov 研究,上下文问题的全貌是:质量下降触发懒惰,容量耗尽触发焦虑。两者都不是"模型被搞混了",而是模型主动选择了少做

解决方案不是更大的 context window(那只是让稻草堆更大)。是 Sub-Agent 架构:

Main Agent(规划 + 编排,昂贵模型 Opus)
  ├── Sub-Agent A(代码库探索,便宜模型 Haiku)→ 只返回文件路径:行号
  ├── Sub-Agent B(安全审计,便宜模型 Haiku)→ 只返回漏洞列表
  └── Sub-Agent C(依赖分析,便宜模型 Haiku)→ 只返回版本建议

每个 sub-agent 在隔离的 context window 里运行,只有最终浓缩的结果传回主线程。主 agent 的上下文始终保持干净、高信噪比。

并发架构:更进一步

当单个 agent 能稳定工作后,下一个问题是:能不能同时派出一百个去干活?

不能直接堆数量。 Cursor 团队的教训:让几百个 agent 共享一份大型项目,当 20 个 agent 同时工作时,有效吞吐量下降到只相当于两三个 agent。原因是上下文互相污染,加上全局资源的争抢。

成熟的并发架构是三层分工:

Planner(规划器)— 分解任务,分配工作,不写代码
  └── Worker(执行器)× N — 各自在隔离环境里执行
        └── Judge(裁判)— 独立验证,不参与执行

配合 DAG 引擎确保工作单向流动,防止循环依赖。

Anthropic 在并发 agent 里找到了另一个优雅的设计:GAN 启发的 Generator + Evaluator 对抗结构。评估者不只看结果,而是亲自动手验货——打开浏览器、点击页面、验证报错栈,像真实用户一样操作一遍。Generator 和 Evaluator 先协商"做完长什么样",再各自工作,形成对抗性的质量保证。

8Lee 的 $team 技能把这个思路推到了极致:8 个独立 agent 做代码评审,最后一个是 Devil's Advocate(唱反调的),专门挑战其他 7 个 agent 的所有建议。它检查严重性评级、标记假阳性、找矛盾。对抗性自我纠正,内置在 skill 结构里。


5. 长时任务 Harness:失忆实习生问题

长时任务 Harness 结构图

这是很多人没有意识到的一个独立问题。

长时任务的核心挑战:Agent 必须在多个 context window 里工作,而每次新的 session 开始时,它完全不记得之前发生了什么。就像一个软件项目由工程师轮班完成,每个新来的工程师对之前的工作没有任何记忆。

Anthropic 在实验中观察到了两个典型失败模式:

  1. "一口气干完":agent 试图一次性完成所有功能,上下文耗尽后留下半成品,下个 session 花时间重建状态,再从头来
  2. "差不多了":agent 看到一点进展就宣布"完成了",然后停工

他们的解法是双 agent 架构

Initializer Agent(初始化 agent),只在第一次运行时启动,建立:

  • feature_list.json:完整功能列表,每项初始为 "passes": false
  • init.sh:一键启动开发服务器
  • claude-progress.txt:每个 session 都会更新的进度日志
  • 初始 git commit

Coding Agent(编码 agent),后续每次 session 开始时执行固定的三步:

# 三步定位:让 agent 快速了解自己的处境
1. pwd                          # 确认工作目录
2. git log --oneline -20        # 了解最近发生了什么
3. cat claude-progress.txt      # 看上一班留下的进度

然后读取 feature_list.json,选优先级最高的未完成功能,一次只做一个,完成即更新状态并 commit。

一个值得注意的细节:用 JSON,不用 Markdown。实验发现,模型倾向于不当地覆盖 Markdown 文件,对结构化 JSON 则克制得多——它只改 "passes" 字段的值,不会擅自删除条目。

这把每个 coding session 变成了一个纯函数:

f(功能列表 + git 历史 + 进度文件) → 完成一个功能 + 更新记录

6. Skills:按需加载,而不是全部预装

大多数人遇到问题的第一反应是:把所有信息塞进系统提示。

结果是:agent 在看完一万 token 的指令之后,剩下的可用注意力所剩无几。OpenAI 把这叫做"1000 页说明书变成陈旧规则的坟场"。

技能(Skills)的解法是按需披露

  • agent 只在需要某个能力时,才加载对应的技能文档
  • 每个技能是一个目录,包含 SKILL.md 和相关资源
  • 加载时,技能内容作为消息注入当前上下文

8Lee 的实现分三层:

Level 1SKILL.md 封面(~100 tokens)——技能发现,Agent 决定是否需要
Level 2SKILL.md 主体(~800-1000 tokens)——阶段图、协议、所有 guards
Level 3:当前阶段的参考文件(~200-600 tokens)——只加载正在执行的阶段

上下文的消耗量始终与当前任务的复杂度成正比,而不是与整个项目的复杂度成正比。


更完整的分析框架:Feedforward + Feedback

Feedforward 与 Feedback 控制矩阵图

Thoughtworks 的 Birgitta Böckeler 提出了一个系统化的思考框架,把 harness 的所有控制机制划分成两个维度。

维度一:控制方向

Feedforward(前馈控制) — 在 agent 行动之前引导它:AGENTS.md 里的规则、架构约束说明、Skill 里的 how-to 指南。

Feedback(反馈控制) — 在 agent 行动之后感知并纠正:测试结果、Linter 输出、类型检查错误。

只有 Feedforward,agent 知道规则但无法验证自己是否遵守了。只有 Feedback,agent 会反复犯同类错误,因为没有预防。两者缺一不可。

维度二:执行类型

Computational(计算型) — 确定性的,CPU 执行:测试、linter、类型检查、结构分析。毫秒到秒级,结果完全可靠,便宜,可以每次提交都跑。

Inferential(推断型) — 语义分析,LLM 执行:AI 代码评审、"LLM 作裁判"。慢而贵,有不确定性,但能处理需要语义判断的场景。

组合起来:

Feedforward Feedback
Computational 架构边界 linter 结构测试、覆盖率
Inferential AGENTS.md 规则、Skills AI 代码评审

最佳实践是:尽量用 Computational,把 Inferential 留给真正需要语义判断的场景

三类 Harness 目标

可维护性 Harness — 最成熟:重复代码、圈复杂度、测试覆盖率、架构漂移,Computational 工具基本都能覆盖。

架构适应性 Harness — 定义和检查架构特征:性能需求前馈 + 性能测试反馈;可观测性约定 + 日志质量检查。

行为 Harness — 最难,仍是开放问题,但正在取得突破。

传统测试框架在这里遭遇根本性失败:你无法给 LLM 的输出写 assertEquals(expected, actual)——相同问题的"正确回答"可以有无数种表达。更深的矛盾是,生成式 AI 的多样性输出不是 bug,是 feature。

突破口是用 AI 测试 AI:不是比对字符串,而是判断意图。一个 AI judge 向另一个 AI 提问:"用户的登录成功了吗?"而不是"div.login-btn 是否存在?"这个 judge 每次运行时重新分析页面 DOM 和截图,给出带推理说明的判断——而非简单的 pass/fail。

PKU 和 HKU 联合推出的 Claw-Eval 基准测试进一步工程化了评估方法:Pass³ 方法论——一个任务必须在三次独立运行中全部通过才算真正通过,彻底消除"幸运运行"的干扰。同时从三个维度评分:Completion(完成度)、Safety(安全性)、Robustness(鲁棒性)。这是在把evaluation harness 本身工程化。


交付侧的 Harness:黄金标准管道

黄金标准管道图

上面讨论的六个组件主要针对 coding agent 的行为控制。但 Harness Engineering 的边界不止于代码生成——从代码到生产的整个交付管道同样需要 harness 化。

Harness 平台工程师 Aditya Kashyap 提出了一个**黄金标准管道(Golden Standard Pipeline)**的四层架构:

Layer 1:治理域(Governance Domain)
  └── 策略即代码(OPA)在管道执行前作为第一道关卡
  └── 原则:不合规的管道不允许启动

Layer 2:集成域(Integration Domain)——内循环
  └── 代码气味、lint、安全扫描并行而非串行
  └── 原则:安全扫描应该让开发提速,而不是增加摩擦

Layer 3:信任域(Trust Domain)——供应链安全
  └── SBOM(软件物料清单):制品的成分表
  └── SLSA 证明:构建过程的不可伪造 ID
  └── 加密签名(Cosign):数字封印,任何篡改都会破坏

Layer 4:交付域(Delivery Domain)——外循环
  └── 不可变制品:构建一次,部署到处
  └── 滚动部署 + 审批门控

其中最重要的是 Layer 1 的哲学转变:传统管道在快要部署时才做合规检查(浪费了前面 20 分钟的构建时间),黄金标准把治理移到"第零步"——不合规的管道甚至不会开始执行

Layer 3 对应了当前软件供应链安全的核心挑战:你需要能证明"这个制品是在哪台机器上构建的、什么时间、用了哪些输入"。当下一个 Log4j 出现时,SBOM 让你不需要扫描整个世界,只需要查询你的制品库存。


实战:Skill 分类学

不是所有任务都同样脆弱。8Lee 提出了基于脆弱性的技能分类:

高脆弱性任务(签名、部署、安全操作)
  └── Hard Gates + 失败即停 + 无恢复重启
  └── 示例:代码签名、公证、加密操作

中脆弱性任务(质量门控)
  └── Quality Gates + 失败即回滚
  └── 示例:依赖更新、staging 部署

低脆弱性任务(lint、格式化)
  └── 简单 pass/fail
  └── 示例:代码格式化、静态检查

在低风险任务上过度约束,浪费 token。在高风险任务上约束不足,迟早出事。


验证反压:成功静默,失败才说话

HumanLayer 认为,agent 解决问题的成功率与它验证自己工作的能力高度相关。

他们建了完整的验证链路:类型检查 + 构建、Biome 格式化 + lint、Playwright 端到端测试、代码覆盖率(低于阈值时强制补写)。

但有一个容易踩的坑:让 agent 每次修改后跑完整测试套件,4000 行的通过输出会塞满上下文窗口,agent 随之开始产生幻觉。

解决方法很简单:成功时不输出任何东西,只有失败才打印详情。

# 成功无输出,失败才打印——context window 零污染
OUTPUT=$(run_build 2>&1)
if [ $? -ne 0 ]; then
  echo "$OUTPUT" >&2
  exit 1
fi

这条原则在所有成功的 harness 设计里反复出现:信号噪比是 context 管理的核心


真实案例:8Lee 的 $zip 命令

这是目前公开记录最详细的 harness engineering 案例。

一条命令 $zip 触发:
├── 12 个顺序步骤(预检、vendor 门控、版本升级、同步、验证...)
├── 65 个验证检查(13 预构建 + 44 核心 + 8 后构建)
├── 5 个编译器(Zig + Swift + Xcodebuild + Go + swiftc)
├── 签名 + 公证 + DMG 打包 + Supabase 上传
├── Vercel 部署(Next.js 下载页面 + API + SEO 元数据)
└── git commit(含 SHA-256 校验文件)+ 文档更新

耗时:约 15 分钟
发布次数:近 1000 次
失败次数:0

他的结论很直接:

"我不再担心发布的正确性了。不是因为 AI 是完美的,而是因为 harness 让「我们一起在做的事」变得安全。"


Harness 应该越来越薄

大多数讨论都在讲"加什么"。但这个洞见值得单独强调:

"Harness 的每一个组件,都编码了一条关于模型做不到什么的假设。当这个假设不再成立,组件就该走了。"

Anthropic 自己做了这件事。随着 Opus 4.5 和 4.6 发布:

  • Context Reset(上下文重置机制):删掉了。新模型的上下文管理能力已经不需要这个补偿。
  • Sprint Contract(冲刺合约,用于控制 agent 执行节奏的约束):删掉了。新模型能自己把控节奏。

每加一个 harness 组件,都是在补偿"当前模型无法独立完成某件事"。每当模型进步让某个补偿变成负担,就该拆掉它。

这同时意味着:今天一些 harness 组件的必要性,来自当前模型的"懒惰"倾向(如前文 Rodionov 的研究所揭示)。Anthropic 的情感向量研究暗示,未来可能可以在模型内部调节这个状态,而不需要外部 harness 补偿——到那时,对应的组件自然退出。

真正的竞争优势不在 harness 的厚度,而在于追踪这个迁移面的速度——知道下一步该加什么,上一步该拆什么。

johng 把这叫做 Harness Engineering 的第六支柱:可拆卸性(Detachability)——以模块化设计构建 harness,让它能随模型迭代优雅退场,而不是每次模型升级都需要大规模重构。


未来三个阶段

我们不会一夜之间拥有完全自主的 SRE 团队。这个演进以三个浪潮的方式推进。

Horizon 1:增强型运营者(当下)

Agent 是工程师的"副驾"。你问"这个 Pod 为什么崩溃了",agent 查日志、关联 MemoryLimitExceeded 错误和最近的配置变更,提出修复建议。人类创建意图并批准行动。

Harness 重点:AGENTS.md + Hooks + 可观测性集成。

Horizon 2:Agent 群体与任务自主(1-2 年)

单个专业化 agent 开始在特定范围内自主处理重复任务。一个"安全 agent"发现 CVE,创建 ticket 并传给"开发 agent",后者建分支、升版本、传给"QA agent"跑测试。人类只在最后点击"合并"。

从 Human-in-the-Loop 转变为 Human-on-the-Loop——你审查输出,但不驾驶过程。

Harness 重点:多 agent 编排 + Judge 模式 + 严格权限隔离(Diagnosis Agent 只有读权限,Remediation Agent 只有目标命名空间的写权限)。

Horizon 3:自主 SRE(3-5 年)

凌晨 2 点生产延迟飙升,"SRE Agent"检测到异常、识别噪音邻居、驱逐节点、验证稳定性、向 Slack 发送事后分析。只有 agent 无法解决时才呼叫人类。

标准操作的 Human-out-of-the-Loop。人类管理策略和目标,不管任务。

Harness 重点:Constitutional AI(Policy-as-Code 通过 OPA 作为所有工具调用的第一道关卡)+ 防篡改审计日志(记录每个推理步骤和每条 CLI 命令)。

每个阶段的关键认知转变:我们不再管理服务器,我们在管理认知架构(Cognitive Architectures)。


开放的硬问题

Harness Engineering 作为一个工程学科仍然年轻。几个核心问题目前没有答案:

代码质量的慢性退化:agent 生成的代码不以人类的方式腐化——不是有 bug,而是"功能正确但逐渐不可维护"。OpenAI 在跑周期性的"垃圾清理 agent",Anthropic 在跑"Doc-gardening agent"(扫描代码和文档的脱节并发起 PR),但这些实践仍很早期。

用 AI 验证 AI 的可靠性:主要靠 AI 生成的测试来验证 AI 生成的代码,这个闭环的可信度是多少?目前没有答案。

老旧代码库的改造:几乎所有成功案例要么从零开始,要么团队在全新项目里构建 harness。把这些方法应用到有十年历史、测试参差不齐、文档残缺的存量代码库,难度是另一个量级。Böckeler 打了个比方:这就像在从未跑过静态分析的代码库上第一次跑——你会溺死在警报里。

Harness 自身的一致性:随着 harness 增长,前馈规则和反馈信号可能开始互相矛盾。当它们指向不同方向时,agent 如何做出合理权衡?如何衡量 harness 的"覆盖率",就像测试覆盖率一样评估它的完整性?目前没有工具可以回答。

概率性系统的信任问题:脚本是确定性的,同样输入永远得到同样输出。Agent 是概率性的,可能根据上下文选择不同路径。让概率性系统可信赖,答案不是消除不确定性,而是确保全程可追溯——只有能被看见的,才能被信任。


从今天开始做什么

第一周:建立基础

  1. 为你最常用的项目创建 AGENTS.md(或 CLAUDE.md

    • 从当前最烦的 5-10 个 agent 失败行为开始
    • 每个写一条规则,一句话,不加解释
    • 总长度控制在 50-100 行
  2. 让 agent 能操作你的项目

    • 所有日常工作流写成 Makefile target(make devmake testmake restart
    • agent 应该能自己启动项目、看日志、跑测试
  3. 建立最小反馈回路

    • linter + 类型检查 + 单元测试,必须能本地快速跑完
    • 失败时才输出,成功时静默

第二到四周:工程化失败

  1. 识别前 5 个最危险的失败模式,把它们变成 hook 拦截脚本

  2. 如果你有跨多个 session 的长任务,建立 Initializer + Coding Agent 双 agent 模式

    • 用 JSON 跟踪功能状态,不用 Markdown
    • 每次 session 开始强制读进度文件和 git log
    • 每次只完成一个功能,完成即 commit
  3. 第一个技能(Skill)——选一个每周都要做的、有多个步骤的任务

持续运转:把每一次失败变成系统

每次 agent 犯错,问自己:

  • 这是 AGENTS.md 可以防止的?→ 加一条规则
  • 这是 hook 可以物理阻止的?→ 写一个拦截脚本
  • 这是 linter 可以检测的?→ 写一条 lint 规则
  • 这是 sub-agent 可以隔离的 context 问题?→ 拆分架构
  • 这是模型已经能自己处理的?→ 删掉这个 harness 组件

唯一的原则:只在 agent 真的出错后才加约束,只在模型真的不再需要时才删约束。


结语:一门关于信任的工程学

构建自动化的历史,一直在回答同一个问题:如何让复杂的多步骤过程变得可靠和可重复?

1976:make         依赖图 + 文件时间戳
1990s:autotools   跨平台构建
2000s:CI/CD       远程机器运行构建
2010s:IaC         可复现的基础设施
2020s:GitOps      声明式期望状态
2026+:Harness     Agent 读取操作手册并执行,harness 管理和约束它

每一代解决了上一代的核心问题,同时引入了新的复杂性。这一代的问题是:如何让 AI 可靠地执行

Böckeler 有一段话值得收在这里:

"人类开发者把技能和经验作为一种隐性 harness 带入每个代码库。我们吸收了约定和最佳实践,我们感受过复杂性带来的认知痛苦,我们知道自己的名字会出现在 commit 里。Harness 是把这些东西外显化、明确化的尝试。但它只能走到某一步。"

Harness Engineering 不是要让人类工程师消失。是要让工程师的经验、品味和判断力,以工程化的方式传递给 AI,让 agent 在你的价值观里工作。

能把自己的工程判断力编写成 harness 的人,就是这个新学科的核心建设者。


参考来源

英文一手资料

中文解析与实践


综合整理自 30+ 篇一手资料与开源项目 | 2026-04-13

Harmony NDK 开发

NDK(Native Development Kit) 是鸿蒙提供的原生开发工具集,允许开发者使用 C/C++ 编写底层代码,通过跨语言调用与 ArkTS 层交互。适用于性能敏感,复用C/C++库,底层硬件操作等场景。

创建 NDK 工程

可以直接使用 DevEco Studio 模板构建 NDK 工程

image.png

创建成功后,目录如下所示:

image.png

CMakeLists.txt 是鸿蒙原生 C++ 模块的构建配置文件,CMake 工具会根据它编译生成动态库(.so文件),供鸿蒙 ArkTS 层调用,我已经逐行解释含义了,不懂得直接看注释即可。

# 声明CMake所需的最低版本
cmake_minimum_required(VERSION 3.5.0)
# 定义项目名称
project(HarmonyApplication)
# 定义变量:CMAKE_CURRENT_SOURCE_DIR 为系统内置变量,代表当前 CMakeLists.txt 所在的文件夹路径
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

# 判断是否定义了 PACKAGE_FIND_FILE 变量,若是则引入该文件,鸿蒙自动生成的兼容配置,用于加载依赖包的配置,开发者无需手动修改
if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif() # CMake 里 if 判断的结束标记,用来闭合 if 语句,CMake 不是 Java,没有大括号 {} 来圈定代码范围

# 添加头文件搜索路径:告诉 CMake,编译 C++ 代码时去这两个路径下查找头文件
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

# 将 napi_init.cpp 编译成名为 entry 的动态库
# add_library:CMake 编译库文件的命令
# entry:最终生成的动态库名称(编译后会得到libentry.so)
# SHARED:指定生成动态共享库(鸿蒙 NAPI 必须用动态库)
# napi_init.cpp:要编译的 C++ 源文件
add_library(entry SHARED napi_init.cpp)

# 为动态库链接依赖库:让我们的动态库能调用鸿蒙 NAPI 接口,实现 C++ 与 ArkTS 的交互
target_link_libraries(entry PUBLIC libace_napi.z.so)

模块级 build-profile.json5 中 externalNativeOptions 参数是 NDK 工程 C/C++ 文件编译配置的入口

image.png

napi_init.cpp 是鸿蒙 NDK 的 “入口文件”,它是 C/C++ 代码 和 ArkTS/JS 代码之间的桥梁,没有它,ArkTS 就调用不了你的 C++ 方法。

它专门负责 3 件事:

  • 注册 Native 模块:告诉系统是一个 C++ 动态库
  • 绑定 C++ 函数:把你写的 C++ 方法暴露给 ArkTS
  • 提供调用入口:让 ArkTS 能像调用普通函数一样调用 C++
#include "napi/native_api.h"

//自定义的 C++ 方法(给 ArkTS 调用)
static napi_value Add(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2] = {nullptr};

    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    napi_valuetype valuetype0;
    napi_typeof(env, args[0], &valuetype0);

    napi_valuetype valuetype1;
    napi_typeof(env, args[1], &valuetype1);

    double value0;
    napi_get_value_double(env, args[0], &value0);

    double value1;
    napi_get_value_double(env, args[1], &value1);

    napi_value sum;
    napi_create_double(env, value0 + value1, &sum);

    return sum;

}

//模块初始化:实现 ArkTS 接口与 C++ 接口的绑定和映射
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr }
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

// 准备模块加载相关信息,将上述 Init 函数与本模块名等信息记录下来。
static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void*)0),
    .reserved = { 0 },
};

// 加载 so 时,该函数会自动被调用,将上述 demoModule 模块注册到系统中。
extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&demoModule);
}

在 cpp\types\libentry\Index.d.ts 文件中,提供 JS 侧的接口方法

export const add: (a: number, b: number) => number;

在 oh-package.json5 文件中将 index.d.ts 与 cpp 文件关联起来

{
  "name": "libentry.so",
  "types": "./Index.d.ts",
  "version": "1.0.0",
  "description": "Please describe the basic information."
}

这些都是由 DevEco Studio 自动生成的,比如我们在 Index.d.ts 中定义一个方法

image.png

然后点击 Generate native implementation,它就能在 cpp 中自动生成对应的 C++ 方法和绑定

image.png

Node-API

  • napi_env:表示 Node-API 执行时的上下文,可以把它理解成 NAPI 给你的一张操作许可证 + 全套工具,所有 NAPI 函数都必须传入它。
  • napi_callback_info:代表 ArkTS 调用 C++ 函数时传递过来的所有信息,专门用来获取 ArkTS 传过来的参数。
  • napi_value:是一个C的结构体指针,表示一个 ArkTS/JS 对象的引用,可以理解为万能的数据载体,是 NAPI 统一的数据类型,可以表示字符串,数字,布尔,数组,对象,null,undefined 等等,C++ 和 ArkTS 之间传递数据只能用它,不能直接传 int,string,bool,必须包装成 napi_value。

这仨的关系,简言之:
ArkTS 调用 C++ 函数 -> 通过 info 拿到参数列表 -> 参数都是 napi_value 类型 -> 用 env 操作这些 napi_value -> 返回一个 napi_value 给 ArkTS

现在来实现一下上面定义的 NAPI_Global_getLast 方法,用来获取数组的最后一个元素。

static napi_value NAPI_Global_getLast(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    // 判断是否为数组
    bool isArray = false;
    napi_is_array(env, args[0], &isArray);
    if (isArray) {
        // 获取数组长度
        uint32_t arrayLength = 0;
        napi_get_array_length(env, args[0], &arrayLength);
        if (arrayLength > 0) {
            // 获取最后一个元素的索引
            uint32_t lastIndex = arrayLength - 1;
            // 获取数组最后一个元素
            napi_value lastElement;
            napi_get_element(env, args[0], lastIndex, &lastElement);
            // 获取字符串长度
            size_t strLen = 0;
            napi_get_value_string_utf8(env, lastElement, nullptr, 0, &strLen);
            // 读取字符串内容
            char resultStr[1024];
            napi_get_value_string_utf8(env, lastElement, resultStr, sizeof(resultStr), nullptr);
            napi_value returnValue;
            // NAPI_AUTO_LENGTH = 让 NAPI 自动计算字符串长度,不用你手动填数字
            napi_create_string_utf8(env, resultStr, NAPI_AUTO_LENGTH, &returnValue);

            return returnValue;
        }
    }
    return nullptr;
}

常用的 Napi 方法

获取调用信息(函数入口必用)

size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

类型判断

napi_is_array:判断是不是数组

bool isArray = false;
napi_is_array(env, args[0], &isArray);

napi_typeof:判断类型

napi_valuetype type;
napi_typeof(env, args[0], &type);

取值

取字符串

char buf[1024];
napi_get_value_string_utf8(env, args[0], buf, sizeof(buf), nullptr);
std::string cppStr = buf;

取数字

double num;
napi_get_value_double(env, args[0], &num);

取整数

int num;
napi_get_value_int32(env, args[0], &num);

取布尔值

bool b;
napi_get_value_bool(env, args[0], &b);

创建值

// 创建数字
napi_value dNum;
napi_create_double(env, 100, &dNum);

// 创建整数
napi_value num;
napi_create_int32(env, 10, &num);

// 创建字符串
napi_value str;
napi_create_string_utf8(env, "Hello", NAPI_AUTO_LENGTH, &str);

// 创建布尔值
napi_value b;
napi_create_boolean(env, true, &b);

// 创建对象
napi_value obj;
napi_create_object(env, &obj);

// 创建数组
napi_value arr;
napi_create_array(env, &arr);

数组操作

// 获取数组长度
uint32_t len;
napi_get_array_length(env, arr, &len);

// 获取数组第 index 个元素
napi_value elem;
napi_get_element(env, arr, index, &elem);

// 设置数组第 index 个元素
napi_set_element(env, arr, index, elem);

对象操作

export const handleUser: (user: UserInfo) => UserInfo;

export interface UserInfo {
  name: string;
  age: number;
}
// ArkTS对象 → C++结构体
struct UserInfo {
    std::string name;
    int32_t age;
};


UserInfo ParseUser(napi_env env, napi_value object) {
    UserInfo info{};
    napi_value nameVal, ageVal;

    // 读取 name
    napi_get_named_property(env, object, "name", &nameVal);
    char nameBuff[64];
    size_t len;
    napi_get_value_string_utf8(env, nameVal, nameBuff, sizeof(nameBuff), &len);
    info.name = nameBuff;

    // 读取 age
    napi_get_named_property(env, object, "age", &ageVal);
    napi_get_value_int32(env, ageVal, &info.age);

    return info;
}

// C++ 结构体 -> ArkTs 对象
napi_value WrapUser(napi_env env, const UserInfo &info) {
    napi_value jsObject;
    napi_create_object(env, &jsObject);

    // 设置 name
    napi_value nameVal;
    napi_create_string_utf8(env, info.name.c_str(), NAPI_AUTO_LENGTH, &nameVal);
    napi_set_named_property(env, jsObject, "name", nameVal);

    // 设置 age
    napi_value ageVal;
    napi_create_int32(env, info.age, &ageVal);
    napi_set_named_property(env, jsObject, "age", ageVal);

    return jsObject;
}

static napi_value NAPI_Global_handleUser(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    
    // 解析入参
    UserInfo userInfo = ParseUser(env, args[0]);
    userInfo.age += 1;
    userInfo.name = "XZJ";
    
    return WrapUser(env, userInfo);
}

浏览器判断控制台是否开启

根据 console.table 的执行时长

这种方案还是可以的,BOSS 用的这个。

function checkIsOpen() {
    const bengin = new Date().valueOf();
    console.table(new Array(100).fill(1).map(item => new Array(100).fill(1)))
    const end = new Date().valueOf();
    console.clear();
    return (end - bengin) > 5
}
console.log(checkIsOpen())

toString 检测 (已经没有用了)

这个方案,是基于console.log不会在控制台开启时执行的前提条件下才会生效,但是目前浏览器这个不行。

function checkIsOpen() {
    
}
checkIsOpen.toString = function() {
    this.isOpen = true;
}
console.log(checkIsOpen)

这个方案已经无了,但是可以了解一下console.log。 console.log() API ‌无论 DevTools 是否打开都会执行‌,但其行为和影响在不同状态下有显著差异。

性能与内存影响不同‌:

  • DevTools 关闭时‌:
    日志输出通常由浏览器轻量处理,‌不会导致内存泄漏‌,堆内存保持稳定。
  • DevTools 打开时‌:
    浏览器会‌保留被打印对象的引用‌(尤其是对象/数组),以便在控制台中展开查看,这可能导致‌内存无法被垃圾回收(GC) ‌,从而引发内存泄漏。nodejs 环境不会内存泄漏

监控debugger

function checkIsOpen() {
    const bengin = new Date().valueOf();
    debugger;
    const end = new Date().valueOf();
    console.clear();
    return (end - bengin) > 5
}
console.log(checkIsOpen())

就是利用断点。

DOM元素检测

就是挂一个隐藏的 html 标签放页面上监控这个 html 标签的offsetHeightoffsetWidth。 这个也无了。

当前端开始做 Agent 后,我才知道 LangGraph 有多重要❗❗❗

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

在之前的内容里面我们一直在用 LangChain 写链、写 Agent,从最简单的模型调用到工具绑定、路由分发、自定义工作流,走了一整套流程。到这里自然会遇到一个问题:随着应用逻辑越来越复杂,LangChain 原有的编排方式开始显得吃力。链是线性的,Agent 是循环的,但真实世界里的流程往往是图状的,有分支、有合并、有回环、有需要等待人工确认的节点。LangGraph 就是为了解决这个问题而出现的。

为什么需要 LangGraph

LangChainAgentExecutor 时,底层逻辑是一个简单循环:调模型、看要不要用工具、用完工具再回来、再调模型。这个模型对于简单的工具调用场景足够用,但一旦遇到以下几种情况,就开始捉襟见肘。

第一种是多步骤分支。假设需要先判断用户意图,然后根据意图走完全不同的子流程,子流程结束后还需要汇总结果再回复用户。AgentExecutor 的循环模型表达这类逻辑,需要把分支全部塞进提示词,或者用条件回调硬写,代码很快就乱成一团。

第二种是状态持久化。用户和 Agent 聊了几十轮,中途关掉了页面,下次再打开希望从上次停下的地方继续。LangChain 本身没有原生的持久化机制,记忆模块只是把消息列表临时存在内存里,进程一停就没了。

第三种是人机协同。工作流执行到某个敏感节点,需要暂停下来等人类审核,审核通过后才能继续往下跑。这种"执行中途打断、人工介入、再恢复"的场景,在 AgentExecutor 里几乎无法干净地实现。

LangGraph 把上面这些问题都纳入了核心设计。它的思路是把整个 Agent 或工作流建模成一张图,节点是计算步骤,边是流转路径,状态是在整张图上流动的数据。图可以有条件边,可以有回边,可以在任意节点打断并恢复,状态可以持久化到数据库。

LangGraph 的核心思路

理解 LangGraph 最好的方式是先搞清楚它的三个基本概念:状态、节点和边。

状态是图执行过程中一直流动的数据对象,可以把它想象成贯穿整个流程的"共享变量包",每个节点都可以读取里面的内容,也可以往里写新的内容。最常用的状态定义是 MessagesAnnotation,它把状态简化为一个消息列表,非常适合对话类应用。如果需要追踪工具调用次数、用户身份、中间计算结果等自定义字段,也可以用 Annotation 自己定义状态结构。

节点是图里的计算单元,每个节点就是一个普通的异步函数,接收当前状态作为参数,执行完后返回需要更新的状态字段。节点可以承担调用模型、执行工具、查询数据库、等待人工审核等任何有意义的计算步骤。

边是节点之间的连接。普通的边直接指向下一个节点,条件边则根据当前状态的内容动态决定下一跳,类似代码里的 if/else。图的执行从特殊的 __start__ 节点开始,到 __end__ 节点结束。

执行时,用户消息随状态流入 callModel 节点,模型回复追加到消息列表后随状态流出,整个过程一进一出,结构极其简单。如需在代码里取出结果,用 result.messages.at(-1) 拿最后一条即可。

下面这张图把五个关键步骤画在一条主线上,如下图所示。

20260317073347

用户发消息进入状态,callModel 节点读取、调用模型、追加回复,状态带着结果流到终点。

再复杂一点,加上工具调用和条件路由,图就具备了循环能力,如下图所示。

20260316231826

加入工具节点和条件边后,调用模型、执行工具、再次调模型形成完整的回路,整个逻辑一眼就能读懂。

LangGraph 和 LangChain 怎么分工

LangGraph 负责"流程怎么跑",它本身不绑定任何模型供应商,也不提供工具的具体实现,只管图的执行调度、状态的流转与持久化。LangChain 负责"工具和模型是什么",它提供的 ChatOpenAItoolHumanMessage、提示模板、检索器这些组件,是节点函数里真正要调用的东西。

两者的关系是分层叠加,而不是二选一,如下图所示。

20260317073508

LangGraph 在上层负责调度与状态,LangChain 在下层提供模型与工具,两者分工明确、协同运作。

如果不确定自己的场景该用哪个,可以对照下面这张表。

场景 推荐
单次问答、简单链式调用 LangChain
一个模型加几个工具的轻量 Agent LangChain
多步骤、有明确分支的工作流 LangGraph
需要持久化对话或状态可回溯 LangGraph
多 Agent 协作、任务拆解 LangGraph
人机协同、需要中途暂停等待审核 LangGraph

LangGraph 的官方文档自己也在说,如果你的 Agent 只是一个简单的"模型加工具循环",用 LangChaincreateReactAgent 快速搞定就好,没必要一开始就引入图的概念。但凡流程复杂到需要明确画出来才能讲清楚,就是 LangGraph 发力的时候了。

最小可运行的骨架

先把三个依赖装好。

pnpm add @langchain/langgraph @langchain/core @langchain/openai

然后搭出下面三个文件的骨架,后面章节的示例都会在这个基础上扩展。

src/model.ts 负责模型初始化,集中管理密钥与接口地址,方便在多个图文件里复用。

// src/model.ts
import { ChatOpenAI } from "@langchain/openai";

export const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
});

src/graph.ts 定义图的结构,目前只有一个调用模型的节点。

// src/graph.ts
import { StateGraph } from "@langchain/langgraph";
import { MessagesAnnotation } from "@langchain/core/messages";
import { model } from "./model";

async function callModel(state: typeof MessagesAnnotation.State) {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("callModel", callModel)
  .addEdge("__start__", "callModel")
  .addEdge("callModel", "__end__");

export const app = graph.compile();

src/index.ts 是入口,执行一次图并打印模型回复。

// src/index.ts
import { HumanMessage } from "@langchain/core/messages";
import { app } from "./graph";

const result = await app.invoke({
  messages: [new HumanMessage("你好,介绍一下 LangGraph")],
});

console.log(result.messages.at(-1)?.content);

现在这个骨架已经是真正可以运行的 LangGraph 应用了:输入一条用户消息,callModel 节点调用模型后把响应追加到状态里,图执行完后取出最后一条消息打印。下一章的 Quickstart 会在这个基础上加入工具绑定、条件边和 checkpointer 持久化,让图逐渐"活"起来。

小结

LangGraph 出现是因为 LangChain 的链式和循环模型在多分支、持久化、人机协同这类复杂场景下力不从心,它用状态、节点、边三个概念把工作流建模成图,状态贯穿全图流动,节点负责处理状态,边决定下一跳的走向。LangChainLangGraph 不是竞争关系,前者提供模型与工具,后者负责编排与调度,两者叠加才是完整的应用架构。后面所有章节的示例都会在 model.tsgraph.tsindex.ts 这三个文件的骨架上扩展。

RainbowKit 快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目需要从原先只支持以太坊主网,扩展到支持 Arbitrum、Polygon、Base 等七八条 EVM 链。老板给的要求很明确:用户体验要丝滑,钱包连接不能卡顿,链切换要直观,最好能快速上线。

我第一时间想到了 RainbowKit。社区里都说它“开箱即用”,封装了 wagmi 和一堆 UI 组件,能省不少事。但真当我动手把文档里的示例代码往项目里一粘,问题就接踵而至了。钱包是能弹出来了,但链列表不对,切换链后前端状态没更新,甚至有的链上交易会报莫名其妙的 RPC 错误。这篇文章,就是我填平这些坑的完整记录。

问题分析

我最开始的想法很简单:照着 RainbowKit 官方文档,安装 @rainbow-me/rainbowkitwagmiviem,然后配置一个 WagmiProviderRainbowKitProvider 把应用包起来不就完事了?代码大概长这样:

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';

const config = getDefaultConfig({
  appName: 'My App',
  projectId: 'YOUR_PROJECT_ID', // 从 WalletConnect Cloud 拿的
  chains: [mainnet, polygon],
});

function App() {
  return (
    <WagmiProvider config={config}>
      <RainbowKitProvider>
        <MyComponent />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

跑起来一看,钱包连接按钮是出来了,点开也能看到 MetaMask、Coinbase Wallet 等选项。但问题立刻出现了:

  1. 链列表不全:我配置了 [mainnet, polygon],但钱包切换网络的弹窗里,有时只显示主网,Polygon 不出现。
  2. 状态不同步:用户在 MetaMask 里手动切换了网络(比如从 Ethereum 切到 Polygon),但我应用里 useAccount() 钩子返回的 chain 信息有时还是旧的,导致后续的合约调用全跑到错误的链上。
  3. 自定义链配置麻烦:像 Base、Arbitrum 这些链,wagmi/chains 里虽然有,但它们的 RPC 节点有时不稳定,我需要换成项目自备的节点,这个配置过程比预想的要绕。

我意识到,“开箱即用”指的是基础功能,一旦涉及到生产环境的多链复杂场景,细节配置一个都不能少。下面我就分步骤拆解我是怎么解决这些问题的。

核心实现

第一步:正确配置多链与 RPC

这里有个大坑:RainbowKit/Wagmi 的链配置,并不仅仅是给组件提供一个列表那么简单。它涉及到钱包连接时向钱包(如 MetaMask)发起“建议”的网络列表,以及 wagmi 客户端内部用来读取链数据、发送交易的 RPC 连接。

我最初只用 wagmi/chains 里导出的链定义,但很快就遇到了公共 RPC 限速或不稳定导致交易失败的问题。解决方案是自定义 viemTransport,并为每条链指定更可靠的 RPC 端点。

// src/config/chains.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';

// 1. 定义项目需要的所有链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;

// 2. 为每条链配置 Transport (RPC 连接)
// 注意:生产环境建议将 RPC URL 放在环境变量中
const transports = {
  [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [base.id]: http('https://mainnet.base.org'), // 也可以使用公共节点
};

// 3. 创建 wagmi 配置
export const config = createConfig({
  chains: supportedChains as any, // 这里有个类型小坑,需要断言
  transports, // 关键!注入自定义的 RPC 传输层
  // ... 其他配置如连接器、SSR 等
});

// 4. 创建 RainbowKit 专用的配置(用于 UI 部分)
export const rainbowKitConfig = getDefaultConfig({
  appName: 'MyDeFiAggregator',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // 必须
  chains: supportedChains,
  transports, // 这里也要传一次,确保一致性
});

关键点transports 配置是性能和安全的关键。使用像 Alchemy、Infura 这样的专业节点服务,能显著提升交易发送和区块数据读取的可靠性。getDefaultConfig 内部其实也是调用了 createConfig,所以我们直接基于 createConfig 来构建,灵活性更高。

第二步:搞定 RainbowKitProvider 与主题

RainbowKit 的 UI 很棒,但默认主题可能和你的项目不搭。集成时,我建议一开始就处理好主题,避免后期再改一堆样式。

// src/providers/Web3Provider.tsx
import { RainbowKitProvider, darkTheme, lightTheme } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/chains';

const queryClient = new QueryClient();

export function Web3Provider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: '#3B82F6', // 自定义主色
            accentColorForeground: 'white',
            borderRadius: 'medium',
            fontStack: 'system',
            overlayBlur: 'small',
          })}
          // 这个 locale 设置对中文用户很友好
          locale="en-US"
          // 可以在这里配置初始链影响连接时的默认网络
          initialChain={polygon}
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

注意这个细节WagmiProvider 需要 @tanstack/react-queryQueryClientProvider 作为上下文来管理请求状态。getDefaultConfig 帮我们隐式创建了 queryClient,但自己显式创建并传入能获得更多控制权,比如设置全局的请求重试、缓存时间等。

第三步:实现链感知的连接与切换

这是用户体验的核心。用户连接钱包后,我们需要清晰地展示当前连接的链,并提供一个便捷的切换方式。RainbowKit 提供了 ConnectButtonChain 组件,但直接使用可能不够。

我遇到的一个典型场景是:用户当前连接在 Polygon 上,但我们的某个功能只支持 Arbitrum。我们需要引导用户切换网络。

// src/components/ChainAwareConnectButton.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useSwitchChain } from 'wagmi';
import { supportedChains } from '@/config/chains';
import { useEffect } from 'react';

export function ChainAwareConnectButton({ requiredChainId }: { requiredChainId?: number }) {
  const { chain, isConnected } = useAccount();
  const { switchChain } = useSwitchChain();

  // 效果:当组件要求特定链,且用户已连接但链不对时,自动提示切换
  useEffect(() => {
    if (isConnected && requiredChainId && chain?.id !== requiredChainId) {
      // 这里可以触发一个自定义的模态框提示,而不是自动切换。
      // 自动切换体验很生硬,可能会被钱包拦截。
      console.warn(`请将网络切换至 ${supportedChains.find(c => c.id === requiredChainId)?.name}`);
    }
  }, [isConnected, chain, requiredChainId]);

  return (
    <ConnectButton.Custom>
      {({
        account,
        chain: connectedChain,
        openAccountModal,
        openChainModal,
        openConnectModal,
        authenticationStatus,
        mounted,
      }) => {
        const ready = mounted && authenticationStatus !== 'loading';
        const connected = ready && account && connectedChain;

        // 自定义按钮渲染逻辑
        return (
          <div
            {...(!ready && {
              'aria-hidden': true,
              'style': {
                opacity: 0,
                pointerEvents: 'none',
                userSelect: 'none',
              },
            })}
          >
            {(() => {
              if (!connected) {
                return (
                  <button onClick={openConnectModal} type="button">
                    连接钱包
                  </button>
                );
              }

              // 如果已连接,但链不符合要求,高亮显示链切换按钮
              const isOnWrongChain = requiredChainId && connectedChain.id !== requiredChainId;

              return (
                <div style={{ display: 'flex', gap: 12 }}>
                  {/* 链切换按钮 */}
                  <button
                    onClick={openChainModal}
                    type="button"
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      background: isOnWrongChain ? '#FEF3C7' : 'transparent', // 链不对时黄色背景提示
                      border: `1px solid ${isOnWrongChain ? '#F59E0B' : '#ccc'}`,
                      borderRadius: '8px',
                      padding: '4px 8px',
                    }}
                  >
                    {connectedChain.hasIcon && (
                      <div
                        style={{
                          background: connectedChain.iconBackground,
                          width: 20,
                          height: 20,
                          borderRadius: 999,
                          overflow: 'hidden',
                          marginRight: 4,
                        }}
                      >
                        {connectedChain.iconUrl && (
                          <img
                            alt={connectedChain.name ?? 'Chain icon'}
                            src={connectedChain.iconUrl}
                            style={{ width: 20, height: 20 }}
                          />
                        )}
                      </div>
                    )}
                    {connectedChain.name}
                  </button>

                  {/* 账户按钮 */}
                  <button onClick={openAccountModal} type="button">
                    {account.displayName}
                    {account.displayBalance ? ` (${account.displayBalance})` : ''}
                  </button>
                </div>
              );
            })()}
          </div>
        );
      }}
    </ConnectButton.Custom>
  );
}

这里有个坑useSwitchChain().switchChain 方法虽然存在,但在浏览器环境中,直接调用它来“强制”用户切换链,体验很差,而且 MetaMask 等钱包可能会阻止这种非用户触发的切换请求。最佳实践是只提供清晰的切换引导(比如高亮链按钮、文字提示),让用户自己点击 openChainModal 去操作。ConnectButton.Custom 给了我们极大的灵活性来实现这种定制 UI 和交互逻辑。

第四步:在应用各处安全地使用链状态

解决了连接和切换,最后一步是确保在需要链信息的任何地方(比如调用合约、查询余额),我们使用的 chainId 都是正确且最新的。

// src/hooks/useSafeChain.ts
import { useAccount, useChainId } from 'wagmi';
import { supportedChains } from '@/config/chains';

// 这个钩子确保返回的 chainId 一定是项目支持的,否则返回 undefined 或默认链
export function useSafeChain(requiredChainId?: number) {
  const { chain } = useAccount();
  const globalChainId = useChainId(); // wagmi v2 的新钩子,直接获取当前链ID

  // 优先级:参数指定 > 当前连接链 > undefined
  let targetChainId = requiredChainId || chain?.id || globalChainId;

  // 检查目标链是否在支持列表中
  const isSupported = supportedChains.some(c => c.id === targetChainId);

  if (!isSupported && targetChainId) {
    console.error(`链 ID ${targetChainId} 不在项目支持列表中。`);
    // 根据业务逻辑,可以在这里触发链切换,或者返回一个默认链(如主网)
    // return mainnet.id;
    return undefined;
  }

  return targetChainId;
}

// 在合约调用处使用
import { useReadContract } from 'wagmi';
import { useSafeChain } from '@/hooks/useSafeChain';
import { myContractAbi } from './abi';

export function MyComponent() {
  const safeChainId = useSafeChain(); // 获取当前安全的链ID

  const { data } = useReadContract({
    abi: myContractAbi,
    address: '0x...', // 注意:不同链上合约地址可能不同,这里需要根据 chainId 做映射
    functionName: 'balanceOf',
    args: ['0xUserAddress'],
    chainId: safeChainId, // 关键!将安全的 chainId 传入查询
    query: {
      enabled: !!safeChainId, // 只有链ID有效时才发起查询
    },
  });

  // ... 渲染逻辑
}

关键点:所有依赖于链的钩子(useReadContract, useWriteContract, useBalance 等),都应该显式地传入 chainId 参数。不要依赖 wagmi 的全局上下文自动推断,因为在复杂的多链交互中,尤其是在用户快速切换网络时,自动推断可能会滞后或出错。useSafeChain 这个自定义钩子相当于一个保险丝,确保后续操作基于一个经过验证的链环境。

完整代码

以下是一个简化但可运行的核心集成示例,基于 Next.js (App Router) 和 TypeScript。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { config } from '@/lib/wagmi-config';

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({ accentColor: '#0E76FD' })}
          locale="en-US"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// lib/wagmi-config.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';

// 1. 定义支持的链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;
export type SupportedChainId = (typeof supportedChains)[number]['id'];

// 2. 配置 RPC Transports
const transports: Record<SupportedChainId, ReturnType<typeof http>> = {
  [mainnet.id]: http(process.env.NEXT_PUBLIC_ETHEREUM_RPC_URL),
  [polygon.id]: http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL),
  [arbitrum.id]: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC_URL),
  [base.id]: http('https://mainnet.base.org'),
};

// 3. 创建 wagmi 配置对象
export const config = createConfig({
  chains: supportedChains as any,
  transports,
  connectors: [
    injected(), // 支持 MetaMask 等注入式钱包
    walletConnect({
      projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
    }),
  ],
  ssr: true, // 如果你用 Next.js 且需要 SSR,开启这个
});
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'My Web3 App',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/page.tsx
'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useBalance } from 'wagmi';

export default function Home() {
  const { address, chain } = useAccount();
  const { data: balance } = useBalance({ address });

  return (
    <main style={{ padding: '2rem' }}>
      <h1>我的多链 DeFi 聚合器</h1>
      <div style={{ margin: '2rem 0' }}>
        <ConnectButton />
      </div>

      {address && (
        <div style={{ marginTop: '1rem', padding: '1rem', border: '1px solid #333' }}>
          <p>连接地址: {address}</p>
          <p>当前网络: {chain?.name} (ID: {chain?.id})</p>
          <p>余额: {balance?.formatted} {balance?.symbol}</p>
        </div>
      )}
    </main>
  );
}

环境变量 (.env.local):

NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=你的_WalletConnect_Cloud_项目ID
NEXT_PUBLIC_ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/your_key

踩坑记录

  1. Unsupported chain 错误:用户连接了钱包,但我的应用配置里没有他钱包当前所在的链(比如 BSC)。RainbowKit 的默认行为是“不支持”,用户会看到一个错误状态。解决方法:在 RainbowKitProvider 上设置 initialChain 为一个你支持的链(如主网),这样新用户连接时会先被引导到该链。对于已连接但链不对的用户,通过自定义 ConnectButton UI 强烈提示他们切换。

  2. WalletConnect 项目 ID 缺失:控制台报错 Invalid projectId解决方法:必须去 WalletConnect Cloud 创建一个项目,获取 projectId。这个 ID 是 WalletConnect 协议连接所必需的,不是可选项。

  3. Hydration 不匹配错误 (Next.js):在服务端渲染 (SSR) 时,服务端没有钱包连接状态,而客户端水合时状态可能不同,导致 React 报错。解决方法:确保 WagmiProviderRainbowKitProvider 只在客户端渲染。在 Next.js App Router 中,将包含这些 Provider 的文件标记为 'use client'。同时,在 createConfig 中设置 ssr: true,让 wagmi 适配 SSR 环境。

  4. 类型错误:chains 类型不匹配:在 createConfig 中直接传入 supportedChains 可能会遇到 TypeScript 类型过于宽泛的问题。解决方法:使用 as const 断言定义链数组,并在 createConfig 处使用 as any 进行临时断言,或者按照 wagmi 类型更精确地定义 Chain 数组。

小结

通过这一轮折腾,我最大的收获是:RainbowKit 确实是加速 Web3 前端开发的利器,但它不是“魔法”。生产级的多链集成,关键在于理解其底层依赖(wagmi, viem)的配置,特别是 TransportchainId 的精确管理。下一步,我可以继续深入优化自定义连接器、钱包连接后的权限验证(SIWE),以及更复杂的跨链状态管理。

WebSocket与SSE技术方案选型对比分析

一、引言

在现代Web应用开发中,实时通信已成为不可或缺的技术需求。无论是社交媒体的即时消息、在线游戏的实时对战,还是金融系统的行情推送,都需要服务端能够主动将数据推送到客户端。WebSocket和Server-Sent Events(SSE)是两种主流的实时通信技术方案,它们各自具有独特的技术特性和适用场景。本文将从技术原理、核心特性、性能表现等多个维度进行深入对比分析,并结合实际业务场景提供选型建议,帮助开发者在不同业务需求下做出合理的技术决策。

实时通信技术的选择直接影响着应用的用户体验、系统可维护性以及运维成本。WebSocket作为一种全双工通信协议,自诞生以来就受到了广泛关注;而SSE作为HTML5规范的一部分,则提供了一种更为轻量级的服务器推送解决方案。两者虽然都实现了服务端向客户端的数据推送,但在协议设计、通信模式、浏览器兼容性等方面存在显著差异。深入理解这些差异对于架构师和开发者在项目初期做出正确的技术选型至关重要,因为这不仅关系到当前项目的开发效率,更影响着后续的扩展和维护成本。

二、技术概述

2.1 WebSocket技术简介

WebSocket是一种在单个TCP连接上提供全双工通信通道的协议,由IETF在RFC 6455中标准化。该协议的设计初衷是为了解决传统HTTP请求-响应模型在实时通信场景下的局限性。在WebSocket出现之前,开发者只能通过轮询或长轮询等变通方案来实现类似效果,这些方案不仅效率低下,还会给服务器带来沉重的负担。WebSocket的出现彻底改变了这一局面,它允许客户端和服务器在建立连接后保持持久的打开状态,双方可以随时相互发送数据,而无需每次都重新建立连接。

WebSocket协议的握手过程基于HTTP协议,利用HTTP的Upgrade机制将普通HTTP连接升级为WebSocket连接。这一设计使得WebSocket能够与现有的网络基础设施无缝兼容,同时也允许WebSocket服务与HTTP服务共享相同的端口。在握手完成后,客户端和服务器之间的通信就完全基于WebSocket协议进行,这与初始的HTTP请求完全不同。WebSocket帧是数据传输的基本单位,支持文本帧和二进制帧两种类型,这使得它能够灵活处理各种类型的数据,包括JSON文本、图片、音视频流等。

从协议层面来看,WebSocket具有几个显著的技术特点。首先是它的全双工特性,客户端和服务器可以在同一连接上同时发送数据,实现了真正意义上的双向通信。其次是WebSocket连接的持久性,一旦连接建立,只要双方不主动关闭或出现网络故障,连接就会一直保持,这避免了重复建立连接的开销。此外,WebSocket协议头部开销很小,在数据传输阶段不需要每次都携带完整的HTTP头部信息,这使得它在高频数据交换场景下具有显著的性能优势。

2.2 SSE技术简介

Server-Sent Events是HTML5规范中定义的一种服务器推送技术,允许服务器通过HTTP协议向浏览器推送事件流。与WebSocket的全双工通信不同,SSE是一种单通道的服务器推送技术,客户端只能接收来自服务器的数据,而不能通过同一连接向服务器发送数据。这种设计使得SSE在需要服务器单向推送的场景下成为一种简洁而高效的解决方案。SSE的技术规范定义在HTML Living Standard中,它利用了HTTP/1.1的分块传输编码机制来实现服务端的数据推送。

SSE的实现基于EventSource接口,这是浏览器原生提供的一个API,开发者可以通过它轻松地建立与服务器的SSE连接并监听服务器推送的事件。与WebSocket相比,SSE的一个显著优势是它完全基于HTTP/1.1协议,无需特殊的协议升级,这使得它在某些受限的网络环境中具有更好的穿透性。此外,SSE还内置了自动重连机制,当连接意外断开时,浏览器会自动尝试重新建立连接,这大大简化了开发者处理连接异常的工作。

从技术实现角度来看,SSE推送的数据采用纯文本格式,每条消息以"data:"开头,以双换行符结束。SSE支持为每条消息设置事件类型和ID标识,客户端可以根据事件类型筛选感兴趣的消息,也可以通过Last-Event-ID请求头从断点恢复数据接收。这些特性使得SSE在实现可靠的消息传递方面具有一定优势,特别是在需要断点续传的场景下。SSE还支持设置消息的重试间隔,提供了基本的连接管理能力。

三、技术原理深度对比

3.1 连接建立机制差异

WebSocket和SSE在连接建立机制上有着本质的不同,这直接影响了它们在不同网络环境下的表现和适用性。WebSocket的连接建立是一个典型的HTTP升级过程,客户端首先发送一个带有Upgrade头的HTTP请求,服务器如果支持WebSocket协议则返回101状态码表示协议切换成功,此后连接就不再是HTTP协议而是WebSocket协议了。这个过程虽然高效,但需要在服务器端实现完整的WebSocket协议栈,并且需要处理协议升级的握手逻辑。

相比之下,SSE的连接建立就是普通的HTTP请求,浏览器通过EventSource API向服务器发送一个GET请求,服务器以text/event-stream的Content-Type持续返回数据。这个请求-响应的模式与传统的HTTP请求完全一致,服务器端只需返回符合SSE格式的数据流即可,无需特殊的协议支持。这种简单性使得SSE可以非常容易地在现有HTTP服务基础上实现,开发者只需几行代码就能将普通的HTTP响应改造为SSE数据流。

在握手细节上,WebSocket还需要处理Sec-WebSocket-Key和Sec-WebSocket-Version等头部,以及基于这些密钥的安全验证过程。这些额外的握手步骤虽然增强了协议的安全性,但也增加了实现的复杂度。而SSE完全沿用HTTP的认证机制,可以使用Cookie、Basic Auth等标准HTTP认证方式,这在某些需要身份验证的场景下更加方便。值得注意的是,WebSocket在握手阶段如果遇到代理服务器或负载均衡设备,可能会因为不认识WebSocket协议而产生问题,而SSE因为本质上是HTTP请求,在这方面的兼容性通常更好。

3.2 数据传输协议对比

WebSocket和SSE在数据传输格式上有着显著差异,这导致了它们在不同数据类型场景下的表现各不相同。WebSocket协议定义了帧的概念作为数据传输的基本单位,支持文本帧和二进制帧两种类型。每帧由操作码、负载长度和负载数据组成,协议设计紧凑高效,没有冗余的文本标记。开发者可以根据需要选择发送文本还是二进制数据,协议本身对数据类型没有限制。

WebSocket的帧结构设计非常精妙,它使用位操作来编码帧的首字节和次字节,包含帧类型、掩码标志和负载长度等信息。对于小于126字节的负载,长度可以直接编码在第二个字节中;对于126到65535之间的负载,使用额外的两个字节来编码长度;而更大的负载则需要使用八个字节来编码64位长度的值。数据负载还可以被分割成多个帧进行传输,接收方需要按照分片协议组装完整的消息。这种灵活的帧机制使得WebSocket能够高效处理任意大小的数据。

SSE的数据格式则是纯文本,每条消息以"data:"前缀开头,消息内容以双换行符结束。这种格式简单直观,人类可以直接阅读和调试。SSE消息支持多行数据,即可以在一个事件中使用多个"data:"行,接收到的数据会以换行符连接成完整的数据内容。每条SSE消息还可以包含event字段指定事件类型,id字段指定事件ID,以及retry字段指定断开后的重试间隔。SSE不支持发送二进制数据,如果需要传输二进制内容,必须先将其编码为Base64等文本格式,这会增加约三分之一的数据量。

从协议开销角度来看,在连接建立后,WebSocket的数据帧头部只有2到10个字节,而SSE每条消息都需要包含"data:"前缀以及可能的event和id字段,文本开销相对较大。对于高频发送小数据量的场景,WebSocket的协议开销优势更为明显;而对于低频发送较大数据块的场景,两者的差距就不那么显著了。SSE的纯文本格式虽然增加了协议开销,但也带来了更好的可调试性,开发者在调试工具中可以直接看到传输的内容。

3.3 连接生命周期管理

WebSocket和SSE在连接生命周期管理方面采取了不同的策略,这直接影响着应用的可靠性和资源消耗。WebSocket连接一旦建立就会持久保持,直到被客户端或服务器主动关闭,或者因为网络故障而断开。由于WebSocket是全双工协议,连接双方都有责任管理连接状态,包括心跳检测、连接超时处理等。服务器通常需要实现心跳机制来检测连接是否仍然有效,如果客户端长时间没有发送数据,服务器可能会主动关闭无效连接以释放资源。

WebSocket连接的关闭需要遵循特定的关闭握手流程,关闭帧包含一个状态码和一个可选的原因描述文本。正常关闭连接时,双方应该交换关闭帧来完成优雅关闭,而不是直接断开TCP连接。这种设计确保了双方都能意识到连接即将关闭,可以做一些清理工作。然而在实际的复杂网络环境中,如移动网络切换、WiFi切换等,TCP连接可能不是正常关闭而是意外中断,这时就需要依赖心跳机制和重连策略来处理。

SSE在连接生命周期管理上则更为简单,它本质上是一个持续的HTTP请求-响应过程。浏览器会自动处理连接的断开和重连,当连接意外断开时,EventSource会自动尝试重新建立连接。如果服务器在响应中设置了retry间隔,浏览器会等待指定时间后重试;如果没有设置,浏览器会使用默认的重试间隔。SSE还支持Last-Event-ID机制,客户端在重连时会将最后接收到的消息ID发送给服务器,服务器可以据此确定从哪里继续推送数据,这对于保证消息不丢失非常有价值。

在服务器端,SSE连接的管理相对简单,因为SSE是单向通信,服务器只需要负责发送数据,不需要处理来自客户端的复杂消息。服务器可以为每个SSE连接维护一些元数据,如连接时间、客户端标识等,并在适当时机主动关闭连接。由于SSE连接基于HTTP协议,它可以利用HTTP/2的多路复用特性,在同一个HTTP/2连接上建立多个SSE流,这在需要向同一客户端推送多种不同数据流时非常有用。

四、核心特性对比分析

4.1 浏览器兼容性考量

在浏览器兼容性方面,WebSocket和SSE作为HTML5规范的一部分,都得到了现代浏览器的广泛支持,但在老旧浏览器和特殊环境中的表现有所不同。WebSocket协议最早在2000年代末期被提出,并在2011年随着RFC 6455的发布而正式标准化。目前,所有主流浏览器,包括Chrome、Firefox、Safari、Edge,都原生支持WebSocket,覆盖了桌面和移动设备。然而,在一些老旧的浏览器中,WebSocket是不可用的,开发者需要使用Flash或轮询作为降级方案。

SSE作为HTML5的一部分,其浏览器支持情况与WebSocket基本一致,所有主流浏览器都支持EventSource API。但SSE在IE和旧版Edge平台上曾长期缺乏支持,IE浏览器从未支持过SSE。对于需要支持IE用户的应用,SSE不可用是一个不可忽视的限制。不过,对于移动端和现代Web应用,SSE的兼容性已经不是问题。需要注意的是,SSE在Service Worker中不可用,而WebSocket则可以正常使用。

在服务器端和网络环境方面,WebSocket因为使用特殊的协议,有时会被防火墙、代理服务器或负载均衡设备阻止,特别是在一些企业网络环境中。这些设备可能不认识WebSocket协议,将其视为可疑连接而主动断开。相比之下,SSE因为使用标准的HTTP协议,几乎不会被防火墙或代理阻止,可以在任何标准HTTP环境中正常工作。这一点是企业内网应用选型时需要重点考虑的因素。

从移动端表现来看,两者都支持良好,但在弱网络环境下的表现有所不同。WebSocket的长连接在移动网络中可能因为网络切换而断开,需要实现重连逻辑。SSE由于使用HTTP协议,可以更好地利用HTTP/2的多路复用和连接复用特性,在某些场景下可能具有更好的网络适应性。不过,现代移动浏览器对WebSocket的支持已经非常成熟,两者的实际体验差异不大。

4.2 性能表现对比

性能是技术选型时需要重点考虑的因素之一,WebSocket和SSE在性能方面各有优势,需要根据具体使用场景进行评估。在协议开销方面,WebSocket在数据传输阶段具有明显的优势。WebSocket帧头部只有2到10个字节,而SSE每条消息都需要包含"data:"前缀等文本标记。对于高频数据交换场景,如实时游戏、在线协作编辑,WebSocket的低开销优势会累积成显著的性能差异。

在服务器资源消耗方面,两者都需要维护持久的连接。WebSocket连接通常被认为更加轻量,因为一旦握手完成,后续的数据传输就非常高效。SSE连接实际上是一个持续的HTTP请求,服务器需要为每个连接维护完整的HTTP上下文,这可能会消耗更多的内存和CPU资源,特别是在高并发场景下。不过,现代HTTP服务器以及各种编程语言的HTTP框架都针对长连接场景进行了优化,SSE的资源消耗问题在实践中通常不是主要瓶颈。

从延迟角度来看,在理想网络条件下,WebSocket和SSE都能提供非常低的延迟,因为两者都避免了轮询带来的固定延迟。WebSocket的全双工特性在需要客户端向服务器发送大量数据的场景下具有优势,因为可以省去建立额外HTTP请求的开销。SSE的单向特性在纯推送场景下反而是一种简洁的优势,不需要维护双向通信的复杂性。在服务器推送频率方面,SSE因为基于HTTP,更容易与CDN配合使用,实现边缘节点的缓存和分发,这对于大规模分发场景非常有价值。

在可扩展性方面,WebSocket因为是长连接,需要服务器采用不同的架构来处理大量并发连接。传统的Apache模型在面对数万甚至数十万的WebSocket连接时会遇到瓶颈,需要使用Nginx、Node.js的cluster模式或者专门的消息队列中间件来实现横向扩展。SSE因为基于HTTP,更容易利用现有的HTTP服务架构和负载均衡方案,在微服务架构中部署更加灵活。两者的扩展性问题都需要在架构设计阶段充分考虑。

4.3 功能特性对比

WebSocket和SSE在功能特性上的差异直接决定了它们各自的适用场景。全双工与半双工是最核心的差异,WebSocket支持客户端和服务器同时发送数据,实现了真正的双向通信;而SSE只支持服务器向客户端推送数据,客户端如果要发送数据需要使用额外的HTTP请求。这种设计差异使得WebSocket成为聊天、游戏、协作编辑等需要频繁双向交互场景的首选,而SSE则更适合股票行情、新闻推送、通知提醒等服务器单向推送场景。

在断线重连方面,SSE内置的自动重连机制为开发者省去了不少麻烦。浏览器会在连接断开后自动尝试重连,并且支持Last-Event-ID机制来实现断点续传。WebSocket则需要开发者自行实现心跳检测和重连逻辑,虽然有各种成熟的开源库可以使用,但这仍然是开发工作的一部分。好消息是,现代WebSocket库通常都提供了完善的心跳和重连功能,开发成本已经大大降低。对于需要保证消息可靠性的应用,两种技术都需要考虑消息确认和重发机制,只是实现方式有所不同。

在消息路由和多路复用方面,SSE通过HTTP/2的多路复用可以更优雅地处理。一个HTTP/2连接上可以建立多个SSE流,每个流可以订阅不同的主题,实现逻辑上的多路复用。WebSocket虽然也可以在同一连接上实现多路复用,但这需要开发者自行实现,或者使用WebSocket的多路复用扩展。在跨域通信方面,两者都支持,但SSE的跨域配置更加简单,只需在服务器响应中添加Access-Control-Allow-Origin头即可;WebSocket的跨域则需要在服务器端实现更复杂的握手逻辑。

在二进制数据支持方面,WebSocket原生支持二进制帧,可以高效传输ArrayBuffer、Blob等二进制数据,非常适合传输图片、音频、视频等多媒体内容。SSE只能传输文本数据,二进制内容必须先进行Base64等文本编码,这会增加数据量并消耗额外的编解码资源。因此,对于需要传输多媒体内容的实时应用,WebSocket是更合适的选择。

五、场景化分析

5.1 实时聊天应用场景

实时聊天是WebSocket最具代表性的应用场景之一,也是WebSocket优于SSE的典型场景。在聊天应用中,用户需要同时发送和接收消息,这就要求通信协议必须支持全双工通信。WebSocket的全双工特性使得它能够完美满足这一需求,用户发送消息时可以直接通过同一个WebSocket连接发送,而接收消息时服务器也可以通过同一连接推送。这种设计不仅降低了延迟,还简化了客户端的实现复杂度。

从消息交互模式来看,聊天应用通常包含多种类型的消息交互,包括一对一私聊、群聊、消息确认、已读回执、在线状态等。这些交互都需要双向通信的支持,WebSocket能够灵活处理各种消息类型,而SSE则无法直接支持客户端向服务器发送消息。虽然可以通过额外的HTTP请求来实现SSE场景下的消息发送,但这样做会增加HTTP请求的数量,影响用户体验,并且需要维护两个独立的通信通道。

在聊天应用的扩展性方面,WebSocket连接需要服务器采用长连接架构。考虑到一个中大型聊天应用可能需要支持数十万甚至数百万的并发连接,服务器架构的设计就显得尤为重要。业界通常采用的方案包括:使用支持高并发的WebSocket服务器,如Node.js的Socket.IO、Java的Netty、Go的gorilla/websocket,使用专门的WebSocket网关服务,或者使用消息队列来实现WebSocket服务的水平扩展。相比之下,SSE在聊天场景下的扩展性挑战更大,因为每个用户的聊天消息都需要推送到对应的SSE连接,架构复杂度会显著增加。

在消息可靠性方面,聊天应用通常需要保证消息的可靠送达。WebSocket应用通常会在协议层之上实现消息确认机制,发送方在收到接收方的确认后才认为消息送达。对于群聊场景,还需要考虑消息的顺序性和一致性。这些需求在WebSocket全双工模式下实现相对自然,而如果使用SSE则需要在HTTP请求层面实现类似的功能,增加系统复杂度。

5.2 实时数据推送场景

实时数据推送是一个广泛的概念,涵盖了金融行情、实时监控、体育比分、新闻更新等多种子场景。在这些场景中,服务器需要持续向客户端推送最新数据,而客户端通常只需要偶尔向服务器发送查询或控制命令。这种服务端推送为主、客户端交互为辅的模式使得SSE成为一种值得考虑的选择。

以股票行情推送为例,用户需要实时看到股价的变动,服务器需要频繁推送最新的成交价、成交量等信息。在这种场景下,数据流向主要是服务器到客户端,客户端主要是偶尔发送查询请求或设置监控条件。如果使用WebSocket,需要为查询请求建立单独的通信通道;如果使用SSE,查询请求可以直接通过普通的HTTP请求实现,响应也会通过SSE连接推送。这种架构更加清晰,也更容易与现有的HTTP服务集成。此外,SSE基于HTTP的特性使得它可以更容易地与缓存、CDN等配合,在数据分发层面具有优势。

在实时监控系统场景中,如服务器监控、物联网设备状态监控等,客户端通常需要实时看到设备的状态变化。这类场景的特点是数据更新频率相对稳定,客户端数量可能很大但单个连接的数据吞吐量不高。SSE在这种场景下的优势在于实现简单,可以利用HTTP的现有基础设施来分发数据。而且SSE的自动重连机制对于监控系统来说非常有用,可以减少因网络波动导致的监控数据丢失。

对于体育比分推送、新闻实时更新等场景,SSE同样是一种非常合适的选择。这类应用的特点是更新频率适中,数据量不大,用户主要是被动接收信息。在这些场景下,SSE的简单性和HTTP兼容性使其成为一种经济实惠的解决方案。如果将来需要支持更多的用户,可以通过HTTP/2甚至HTTP/3来提升单个连接的承载能力,而无需修改应用代码。

5.3 多人协作编辑场景

多人协作编辑是WebSocket的典型应用场景之一,如Google Docs式的在线文档协作。这类应用的核心需求是多个用户同时编辑同一个文档,所有参与者的修改需要实时同步到其他人的视图中。这种场景对通信协议有很高的要求:需要支持低延迟的双向通信,需要处理复杂的并发冲突,需要保证操作的顺序性。

在协作编辑中,用户的每一个操作,如输入字符、删除、格式化等,都需要即时发送到服务器,然后广播给其他参与者。如果使用SSE来实现这一功能,客户端发送操作需要额外的HTTP请求,而HTTP请求的建立本身就有延迟,包括DNS查询、TCP握手、TLS握手等,这会导致操作同步的延迟增加,影响用户体验。WebSocket的持久连接特性使得客户端可以立即发送操作数据,无需等待HTTP请求的建立。

更重要的是,协作编辑通常需要实现操作转换或冲突解决算法,这些算法需要在客户端和服务器之间频繁交换状态信息。例如,当两个用户同时在相同位置插入字符时,服务器需要协调两边的操作顺序,并将调整后的结果广播给所有参与者。这种密集的双向交互在WebSocket全双工模式下非常自然,而在SSE模式下则需要额外的机制来处理客户端到服务器的数据传输。

从扩展性的角度来看,协作编辑应用通常需要支持大量并发连接。以一个流行的在线文档服务为例,可能同时有数百万用户在编辑文档,每个文档可能有多个参与者。WebSocket服务的扩展需要采用分布式架构,包括连接状态的同步、消息的路由、负载均衡等。SSE虽然也可以实现类似的架构,但因为其HTTP本质,在协作编辑这种高频双向交互场景下的效率劣势会更加明显。

5.4 推送通知场景

推送通知是一个覆盖范围很广的场景,包括Web推送、移动推送、邮件通知等子场景。在Web推送场景中,Service Worker扮演着重要角色,它允许Web应用在后台接收服务器推送的消息。从协议选择的角度来看,Web推送通常只涉及服务器到客户端的单向数据流,这使得SSE成为一种可行的选择。

然而,需要注意的是,浏览器原生提供的Web Push API是基于WebSocket的,这是W3C和IETF共同制定的标准。Web Push使用了WebSocket来建立安全的推送通道,并在其上定义了完整的订阅和推送机制。选择使用浏览器原生的Web Push API还是自行实现基于SSE的推送系统,需要根据具体需求来决定。如果需要支持跨浏览器的一致推送体验,使用Web Push是更好的选择;如果只需要在特定浏览器中工作,且需要更灵活的控制,自行实现SSE推送也是可行的。

在企业内部系统、后台管理系统等场景中,推送通知通常以模态框、Toast提示或者小红点等形式呈现。这类场景的特点是通知频率不高,对实时性的要求相对宽松。对于这类应用,SSE是一种简洁高效的解决方案,可以利用现有的HTTP基础设施,不需要专门的WebSocket服务器。开发者只需在服务器端实现SSE端点,在客户端使用EventSource API即可快速实现推送功能。

从安全角度来看,无论是WebSocket还是SSE,都需要考虑传输加密和使用身份验证。在SSE场景下,可以直接利用HTTP的Cookie或Authorization头进行身份验证;而WebSocket则需要在握手阶段或连接建立后自行实现认证机制。对于已经使用HTTP认证的应用,SSE在安全性实现上会更加自然。

六、选型决策矩阵

6.1 核心决策因素

在实际项目中选择WebSocket还是SSE,需要综合考虑多个因素。以下是几个核心的决策维度,每个维度都会影响最终的技术选择。

通信模式需求是首先要考虑的因素。如果应用需要频繁的双向数据交换,如聊天、游戏、协作编辑,WebSocket是必然的选择。如果主要是服务器向客户端推送数据,如通知、新闻、数据监控,则需要进一步评估其他因素。在某些场景下,即使是看似单向的推送,也可能因为需要频繁查询或控制而产生大量的客户端到服务器的数据流,这时WebSocket可能仍然是更好的选择。

浏览器兼容性要求也是重要的考量因素。如果应用需要支持IE浏览器或老旧的移动浏览器,SSE的不可用性就是一个严重问题,必须选择WebSocket并准备降级方案。如果目标用户主要使用现代浏览器,则两种技术都是可选的。在企业内网环境中,如果存在严格的网络策略,SSE的HTTP兼容性优势可能更加重要。

服务器端实现复杂度影响着开发和维护成本。SSE可以在任何HTTP服务器上实现,无需特殊的协议支持,这使得它更容易集成到现有的Web应用中。WebSocket需要专门的服务器端支持,虽然有丰富的开源库可用,但这仍然是需要维护的额外组件。对于已有成熟HTTP服务架构的团队,SSE可能更容易上手。

扩展性需求决定了系统的长期可维护性。如果预计系统需要支持大量并发连接,需要提前考虑连接管理和水平扩展的问题。WebSocket在这方面需要更多的架构设计投入,而SSE则可以更容易地利用HTTP基础设施来实现扩展。

6.2 典型场景推荐

基于上述分析,我们可以为常见的典型场景给出技术选型建议。

强烈推荐WebSocket的场景包括:实时聊天应用、在线游戏、多人协作编辑、需要双向交互的任何应用。这些场景的核心需求是高频双向通信,WebSocket是唯一合理的选择。在这些场景下,SSE要么无法满足需求,要么需要通过额外的机制来实现客户端到服务器的数据传输,增加系统复杂度。

推荐SSE的场景包括:股票行情推送、实时监控仪表盘、新闻feed更新、邮件或通知推送。这些场景的核心特点是服务器到客户端的推送为主,客户端的请求相对较少或不频繁。SSE在这些场景下可以实现简洁高效的解决方案,并且更容易与现有的HTTP架构集成。

两者皆可的场景包括:简单的状态同步、事件通知、后台任务进度展示。这些场景对实时性的要求不高,数据量也不大,技术选择更多取决于团队的技术栈熟悉度和现有的基础设施。如果团队对WebSocket更熟悉,那么使用WebSocket也完全合理;反之亦然。

6.3 混合使用策略

在实际应用中,WebSocket和SSE并不是互斥的选择,有时候可以考虑混合使用来发挥各自的优势。

一种常见的混合策略是在同一个应用中为不同的功能模块选择不同的通信技术。例如,在一个社交应用中使用WebSocket处理聊天功能,而使用SSE处理动态或feed的推送。这种设计可以充分发挥各自的优势:聊天需要频繁的双向交互,使用WebSocket;feed推送主要是单向的服务器推送,使用SSE可以简化实现。在这种架构下,客户端需要同时维护WebSocket连接和SSE连接,增加了客户端的复杂度,需要仔细权衡。

另一种混合策略是将WebSocket用于实时性要求高的数据,而将SSE用于实时性要求相对较低但更可靠的数据传递。例如,在金融交易应用中,使用WebSocket推送实时成交数据,而使用SSE推送账户资金变动等重要通知。这种设计可以在性能和可靠性之间取得平衡。

从架构角度来看,无论是WebSocket还是SSE,在大规模部署时都需要与消息队列、缓存等中间件配合。将推送服务抽象为一个独立的推送网关,对外提供统一的接口,而内部可以根据数据特点选择不同的通信协议,这种设计可以提供更大的灵活性。客户端可以通过单一的接入点订阅不同类型的数据,而网关内部负责路由和分发。

七、代码示例对比

7.1 WebSocket服务端实现示例

以下是一个使用Node.js实现的WebSocket服务端示例,展示了WebSocket的基本用法。这个示例创建了一个简单的WebSocket服务器,当客户端连接时会发送欢迎消息,并处理客户端发送的消息然后广播给所有连接的客户端。这种模式是实时聊天应用的基础架构,开发者可以在此基础上添加房间管理、消息持久化等功能。

const WebSocket = require('ws');
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('WebSocket Server');
});

const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
    console.log('Client connected');
    ws.send('Welcome to the chat server!');

    ws.on('message', (message) => {
        console.log('Received:', message.toString());
        // 广播消息给所有连接的客户端
        wss.clients.forEach((client) => {
            if (client.readyState === WebSocket.OPEN) {
                client.send(message.toString());
            }
        });
    });

    ws.on('close', () => {
        console.log('Client disconnected');
    });
});

server.listen(8080, () => {
    console.log('WebSocket server started on port 8080');
});

7.2 SSE服务端实现示例

以下是一个使用Express框架实现的SSE服务端示例。SSE的实现更加简洁,服务器只需要设置正确的响应头,并持续向客户端发送事件流即可。这种简洁性是SSE的一大优势,特别适合快速原型开发和简单场景。开发者可以在这个基础上添加身份验证、连接管理等高级功能。

const express = require('express');
const app = express();

app.get('/events', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // 每秒发送一条消息
    const intervalId = setInterval(() => {
        const data = {
            time: new Date().toISOString(),
            message: 'Server time update'
        };
        res.write(`data: ${JSON.stringify(data)}\n\n`);
    }, 1000);

    // 当客户端断开连接时清理资源
    req.on('close', () => {
        clearInterval(intervalId);
        console.log('Client disconnected');
    });
});

app.listen(3000, () => {
    console.log('SSE server started on port 3000');
});

7.3 客户端实现对比

WebSocket客户端实现通常需要手动处理重连逻辑、心跳检测等:

const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
    console.log('Connected to WebSocket server');
    // 启动心跳
    setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'ping' }));
        }
    }, 30000);
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

ws.onclose = () => {
    console.log('Connection closed, reconnecting...');
    // 实现重连逻辑
    setTimeout(() => connect(), 3000);
};

SSE客户端使用EventSource API,自动处理重连:

const eventSource = new EventSource('http://localhost:3000/events');

eventSource.onopen = () => {
    console.log('Connected to SSE server');
};

eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

eventSource.addEventListener('customEvent', (event) => {
    const data = JSON.parse(event.data);
    console.log('Custom event:', data);
});

八、总结与展望

8.1 技术选型总结

WebSocket和SSE作为两种主流的实时通信技术,各有其独特的优势和适用场景。WebSocket以其全双工通信特性成为需要双向交互场景的首选方案,从实时聊天到在线游戏,从协作编辑到金融交易,它能够满足各种复杂实时通信需求。WebSocket的低协议开销、灵活的数据类型支持也使其在高频数据交换场景下具有明显优势。然而,WebSocket的实现和维护需要更多的技术投入,特别是在大规模部署时需要考虑连接管理和水平扩展的问题。

SSE则以其简洁性和HTTP兼容性在服务器单向推送场景下展现出独特的价值。对于股票行情、实时监控、新闻推送等场景,SSE提供了一种轻量级且易于实现的解决方案。SSE的内置重连机制和HTTP基础设施的天然支持使其在某些受限环境中具有更好的穿透性。但SSE不支持双向通信、不支持IE浏览器、不支持二进制数据传输等限制也使其不适合某些场景。

在实际技术选型过程中,建议开发者首先明确应用的通信模式需求。如果主要是服务器向客户端推送数据,SSE是一个值得考虑的选项;如果需要频繁的双向交互,WebSocket是必然的选择。在此基础上,还需要综合考虑浏览器兼容性要求、团队技术栈、现有基础设施、扩展性需求等因素,做出最适合项目实际需求的选择。

8.2 技术发展趋势

实时通信技术仍在不断演进,了解其发展趋势有助于做出更具前瞻性的技术决策。

在协议层面,HTTP/3基于QUIC协议正在逐步普及,其在连接建立速度、丢包处理、多路复用等方面的改进对WebSocket和SSE都有潜在影响。HTTP/3原生支持WebSocket扩展,可以在HTTP/3连接上更高效地运行WebSocket。对于SSE来说,HTTP/3的改进也能提升其性能表现。预计在未来几年,HTTP/3将成为Web实时通信的重要基础。

在应用层面,边缘计算和CDN的进一步发展可能改变实时通信的架构模式。将推送服务部署在边缘节点可以进一步降低延迟,提升用户体验。WebSocket和SSE都可能受益于这种架构演进,但SSE因为其HTTP本质可能更容易与CDN配合。

在生态系统方面,WebSocket和SSE的各种高级库和框架仍在不断完善,为开发者提供更易用的抽象。例如,Socket.IO、SignalR等库提供了房间概念、自动重连、跨浏览器兼容等功能,大大降低了WebSocket的使用门槛。同时,无服务器架构的兴起也对实时通信提出了新的挑战和机遇,如何在无服务器环境中维护长连接是一个值得关注的方向。

nestjs实战-登录、鉴权(一)

一个完整的登录流程中至关重要的就是它的认证方式,现有的认证方式主要有一下3种:

  • session/cookie
  • JWT(Json Web Token)
  • Oauth

一、鉴权方式

Session/Cookie

原理:用户登录后,服务器在内存中创建 Session,并将 Session ID 通过 Cookie 返回给客户端。后续请求自动携带 Cookie。

特点:适用于传统Web应用,有状态,不适合跨域或高并发环境

优点:

  • 较易扩展
  • 开发简单

缺点:

  • 需要在服务端存储session,性能低、多服务器同步session困难
  • 由于cookie只在浏览器上能使用,所以它跨平台困难

JWT

原理:服务端通过特定密钥生成包含用户信息的签名字符串(Token),客户端在请求头(Authorization: Bearer Token)中携带。服务器不存储会话,只验证签名。

特点:无状态、扩展性好,适用于微服务和单页应用(SPA)、跨平台能力

JWT 本质上是一个经过数字签名的字符串,它由三部分组成,用点号(.)分隔:Header.Payload.Signature。token长什么样?

Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOiIyMDAwNCIsInJuU3RyIjoiQVpVUVY2dnAyUllkOFRucnJoaXNsQWtRcTFhSEFxeTAiLCJ0ZW5hbnRJZCI6IjEiLCJzY29wZSI6IlJPTEU6Ok1JQ1JPX0FQUCIsImFwcEtleSI6InJIWnJZeVB1eW1oaXZlSUUiLCJuaWNrTmFtZSI6IuWui-Wwj-aXrSIsImxvZ2luVGltZSI6MTc3NTYxMzI4NzYxNX0.bq8qEE-imza7pLucKOvKw2WvukW2lSPr1WMbAcuJLmU

优点:

  • 支持跨平台:移动端、跨应用
  • 安全、承载信息丰富

缺点:

  • 刷新与过期处理
  • payload不易过大
  • 中间人攻击(没有绝对的安全)

Oauth

第三方授权,例如 微信、支付宝、QQ、谷歌账号登录等

优点:

  • 开放、安全、简单
  • 权限指定

缺点:

  • 需要增加授权服务器
  • 增加网络请求

二、实战

首先 创建身份验证模块,在指定的目录下执行如下命令:

nest g res auth

整个流程我把它分成两部分:

  1. 用户登录过程
  2. 登录成功后,带token请求业务接口的过程

2.1 前置知识

此段内容篇理论介绍,可以先看后面的代码实现,有疑问再来看这里的内容

模块之间的复用

auth模块中如何使用 users 模块中的 users.service.ts 中的方法

之前的章中我们创建了 Users 模块,现在 auth 模块中想要使用 users.service 中的方法:

  • 首先需要在 users.module.ts 中导出 users.service.ts

    // ... 省略
    @Module({
      imports: [TypeOrmModule.forFeature([UserEntity])],
      controllers: [UsersController],
      providers: [UsersService],
      exports: [UsersService], // 导出
    })
    export class UsersModule {}
    
    
  • auth.module.ts 中导入

    import { Module } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { AuthController } from './auth.controller';
    import { UsersModule } from '../users/users.module';
    
    @Module({
      imports: [UsersModule],
      controllers: [AuthController],
      providers: [AuthService],
    })
    export class AuthModule {}
    
  • 这样就能在 auth.service.ts 中使用了

    import { Injectable } from '@nestjs/common';
    // 注意这里在使用中还是需要 显示的引入,而不是会自动引入
    import { UsersService } from '../users/users.service';
    
    @Injectable()
    export class AuthService {
      constructor(private readonly usersService: UsersService) {}
    
      async register(createUserDto: any) {
        return this.usersService.findAll();
      }
    }
    

JWT 相关包介绍

首先安装依赖:

 pnpm add @nestjs/passport passport passport-jwt @nestjs/jwt 
pnpm add @types/passport @types/passport-jwt -D

在集成 JWT 之前,先学习一下使用到的包,并了解它都提供了哪些功能。这里是掌握整个流程非常重要的环境,参考了网上(包括官网)一上来就是介绍怎么使用、代码怎么写,确实是让人很迷惑。

1. @nestjs/passport
  1. 集成 Passport.js:让 NestJS 应用能够使用 Passport.js 提供的超过 500 种认证策略(如本地用户名密码、JWT、OAuth 2.0 等)。
  2. 提供装饰器:它提供了一系列装饰器(如 @UseGuards(AuthGuard('jwt'))),让你可以非常方便地在控制器(Controller)或路由上应用认证保护。
  3. 简化策略创建:通过 PassportStrategy 类,你可以轻松地创建自定义策略,而无需直接处理 Passport.js 的底层 API。
import { PassportModule, PassportStrategy, AuthGuard } from '@nestjs/passport';
PassportModule
  • 类型: NestJS 模块 (@Module)

  • 作用: 它是整个 Passport 集成的入口。你需要把它导入到你的 NestJS 模块(如 AuthModule)中,才能启用 Passport 功能。

  • 核心功能

    • 它通过 .register() 方法允许你配置全局选项,比如指定默认的认证策略(defaultStrategy)。
    • 它负责将 Passport 的服务注入到 NestJS 的依赖注入容器中
PassportStrategy-策略实现
  • 类型:抽象类 / 辅助函数

  • 作用:它是用来创建具体认证逻辑的基类。Passport.js 本身有各种策略(如 Local, JWT, Google),这个函数帮助我们将这些策略适配到 NestJS 的类结构中。

  • 核心功能

    • 它接受一个具体的策略类(如 passport-localStrategy)作为参数。
    • 它让你重写 validate 方法,在这里编写具体的验证逻辑(比如查数据库比对密码)。
AuthGuard-路由保护
  • 类型:守卫 (CanActivate)

  • 作用:它是 NestJS 的守卫,用于保护路由。它拦截请求,并告诉 Passport 执行哪个策略。

  • 核心功能

    • 它通常配合 @UseGuards() 装饰器使用。
    • 它接收一个参数(字符串),这个字符串必须与你定义的策略名称(或默认名称)匹配,从而触发对应的验证流程。
2. @nestjs/jwt

@nestjs/jwt 是 Nest 官方对 JWT 的封装包,底层基于 jsonwebtoken。它主要解决三件事:

  1. 在 Nest 里统一配置 JWT 密钥、过期时间等(模块化)
  2. 提供 JwtService 来签发和校验 token
  3. 很容易和 Passportjwt strategy 集成成认证体系
import { JwtModule, JwtService } from '@nestjs/jwt';
JwtModule:模块注册器
  • 用来在 Nest DI 容器里注册 JWT 相关配置和服务

  • 常见用法:

    • JwtModule.register({...}) 静态配置
    • JwtModule.registerAsync({...}) 动态读取配置(比如从 ConfigServiceJWT_SECRET
JwtService:具体干活的服务

用它生成 token、验证 token、解码 token,一般在 AuthService 里注入并调用

  • sign(payload, options?)

    • 作用:根据 payload 生成 JWT 字符串
    • 示例:this.jwtService.sign({ sub: user.id, name: user.name })
    • options 可以覆盖模块默认配置,比如 expiresIn, secret
  • verify(token, options?)

    • 作用:验证 token 是否合法、是否过期,并返回解码后的 payload
    • 验证失败会抛异常(如签名不对、过期)
    • 示例:this.jwtService.verify(token)
3. passport-jwt

passport-jwt 这个包的核心作用是:给 Passport 提供“JWT 认证策略”。

import { ExtractJwt, Strategy } from 'passport-jwt';

主要用到两个能力:

  • Strategy

    • JWT 的 Passport 策略类
    • 你在 Nest 里通常写 extends PassportStrategy(Strategy, 'jwt')
    • 用来定义:用什么密钥验签、是否忽略过期、验证成功后返回什么用户信息
  • ExtractJwt

    • token 提取器工具
    • 最常用:ExtractJwt.fromAuthHeaderAsBearerToken()
    • 表示从 Authorization: Bearer <token> 里取 token
    • 也支持从 cookie、query、或自定义函数提取

简化理解:

  • @nestjs/jwt 偏向“签发/校验 token 的服务能力”
  • passport-jwt 偏向“请求进来时怎么从请求里拿 token 并走认证策略”

二者经常一起用:登录时 JwtService.sign() 发 token,请求鉴权时 passport-jwtStrategy 来验。

4. 总结

首先为什么需要安装 passport:

仅安装 @nestjs/passport 是不够的。简单来说,@nestjs/passport 只是一个“适配器”,它的核心作用是将 passport 的功能封装成 NestJS 熟悉的模块和依赖注入形式,但它本身并不包含 passport 的核心逻辑。

你可以这样理解它们的关系:

  • passport: 这是核心的认证库,提供了所有的认证策略和逻辑。
  • @nestjs/passport: 这是一个 NestJS 的官方封装包,它让你能以更符合 NestJS 风格(如使用装饰器、依赖注入)的方式来使用 passport

完整的登录鉴权依赖

要实现一个完整的登录鉴权功能,你通常需要安装以下几个包:

  1. 核心库:

    • passport: 认证的核心。
    • @nestjs/passport: NestJS 的适配器。
  2. 具体策略库 (根据你选择的认证方式):

    • 用户名密码登录: 需要 passport-local
    • JWT 无状态认证: 需要 passport-jwt
  3. 类型定义 (TypeScript 项目需要):

    • @types/passport
    • @types/passport-local (如果使用 local 策略)
    • @types/passport-jwt (如果使用 jwt 策略)

2.2 JWT 集成

auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';

import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { securityRegToken, ISecurityConfig } from '~/config';
import { isDev } from '~/global/env';

@Module({
  imports: [
    PassportModule, // 引入 PassportModule 模块
    // 引入 JwtModule 模块,及配置 JwtModule 模块
    JwtModule.registerAsync({
      imports: [ConfigModule], // 引入 ConfigModule 模块
      useFactory: (configService: ConfigService) => {
        const { jwtSecret, jwtExprire } = configService.get<ISecurityConfig>(securityRegToken)
        return {
          secret: jwtSecret, // 设置密钥
          signOptions: { expiresIn: jwtExprire }, // 设置过期时间
          ignoreExpiration: isDev, // 开发环境忽略过期时间
        }
      },
      inject: [ConfigService], // 注入 ConfigService 服务
    }),
    UsersModule,
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
  ],
})
export class AuthModule {}

以上代码 主要是 引入了 PassportModule, JwtModule,并完善了相关配置;

2.3 用户登录过程

  • 用户输入 用户名、密码 等信息

    通过http(加密|明文),传输给后端

  • 后端接受到来自前端 的http 请求

    • 验证账号密码是否一致
    • 生成 token 传递给前端

auth.controller.ts

import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';

import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
  ) {}

  // 登录
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

auth.service.ts

import { HttpException, Injectable, HttpStatus } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { isEmpty } from 'lodash';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async login(loginDto: {
    name: string;
    password: string;
  }) {
    const { name, password } = loginDto;
    const user = await this.usersService.findUserByUserName(name);
    if (isEmpty(user)) {
      throw new HttpException('用户不存在', HttpStatus.NOT_FOUND);
    }

    const { password: userPassword } = user;
    if (userPassword !== password) {
      throw new HttpException('密码错误', HttpStatus.BAD_REQUEST);
    }

    // 生成 JWT 签名
    const jwtSign = await this.jwtService.signAsync({
      id: user.id,
      name: user.name,
      pv: 1, // 版本号
    })
    return {
      access_token: jwtSign
    };
  }
}

用户登录过程代码如上,还是比较简单:

  • 获取用户传递过来的参数,校验用户是否存在,密码是否正确
  • 生成JWT签名,返回token給前端

功能主线如此,密码加密、token管理等有待完善

2.4 业务接口token验证过程

由于文章篇幅,放到下一节讲解;

受够了空格翻页?我写了一个 Chrome 自动滚动插件,让你真正沉浸式阅读

痛点:你一定遇到过这些问题

阅读长文章时,你有没有这样的体验?

1. 空格键翻页,永远翻不到想要的位置

浏览器默认的空格键翻页,一按就是整屏跳过去,经常跳过关键内容。你得来回滚动找刚才读到哪了,阅读节奏完全被打断。

2. 手动滚轮,手指累得够呛

读一篇万字长文,你得不停地滚鼠标滚轮。读技术文档就更夸张了,一边看一边滚,注意力全被分散了。

3. 现有自动滚动插件,不够好用

Chrome 应用商店里也有一些自动滚动插件,但普遍存在这些问题:

  • 速度调节不够精细,要么太快要么太慢
  • 没有方便的快捷键暂停机制,想停下来看某段内容很麻烦
  • 交互逻辑粗糙,滚动中点击页面或者用滚轮都会导致混乱
  • 我希望通过空格就可以开启滚动,再按暂停滚动, 自己滑时暂停滚动, 并且可以通过左右键盘按键直接调节滚动速度

所以我造了个轮子:Auto Scroll Reader

GitHub 地址:github.com/s2265681/au…

一款轻量级 Chrome 自动滚动插件,让你解放双手,沉浸阅读。

output3.gif

核心特性

1. 匀速丝滑滚动,告别空格跳页

插件采用 setInterval + 像素级滚动的方案,而不是浏览器原生的 scrollBy({ behavior: 'smooth' })。每次只滚动 15.5 个像素,间隔 1660ms,实现真正的匀速丝滑滚动,阅读体验就像在看一条平稳流动的文字河流。

2. 10 档速度,精细可调

提供 1-10 档速度调节,覆盖从「逐字细读」到「快速浏览」的全部场景:

速度档位 滚动间隔 每次像素 适合场景
1 (最慢) 56ms 1px 逐字精读、代码阅读
3 (默认) 46ms 2px 普通文章阅读
5 36ms 3px 快速浏览
10 (最快) 16ms 5.5px 速览/找内容

你可以通过 Popup 面板的滑块调节,也可以直接用左右方向键实时调速,页面会弹出 Toast 提示当前速度,完全不用打开插件面板。

3. 空格键一键暂停/继续

这是我最满意的设计。按下 Space 键即可暂停/恢复滚动,和阅读场景天然契合:

  • 看到感兴趣的段落?空格暂停,慢慢看
  • 看完了?空格继续,接着滚
  • 输入框内打字时?自动屏蔽,不会误触发

再也不用去找鼠标点按钮了。

4. 智能暂停机制

插件不是"一根筋"地滚动,它能感知用户的操作意图:

  • 鼠标滚轮:滚动中你突然用滚轮,自动暂停,空格可恢复
  • 点击页面:点击空白区域自动暂停(点击链接和按钮不受影响)
  • 触摸滑动:移动端触摸操作同样会暂停

这意味着你随时可以"接管"控制权,不需要刻意去关闭自动滚动。

5. 智能识别滚动容器

很多网站的正文并不在 window 上滚动,而是在某个 div 容器内。插件会自动检测页面中可滚动的容器(匹配 mainarticle[class*="content"][class*="reader"] 等语义化标签),确保在各种页面布局下都能正常工作。

6. 全局开关 + 跨 Tab 同步

Popup 面板提供了一个总开关,关闭后所有页面的自动滚动都会停止,快捷键也不会响应。开关状态通过 chrome.storage.sync 存储,跨 Tab 实时同步

快捷键一览

快捷键 功能
Space 开始 / 暂停滚动
减速
加速

就这三个键,足够了。

技术实现亮点

作为一个开发者,简单聊聊技术细节:

Manifest V3

插件基于 Chrome 最新的 Manifest V3 规范开发,使用 Service Worker 作为 background script,符合 Chrome 未来的扩展标准。

防重复注入

if (window.__autoScrollReader) return;
window.__autoScrollReader = true;

Content Script 通过全局标记防止重复注入,避免多次执行导致多个滚动定时器同时工作。

自动注入已打开的 Tab

安装或更新插件后,会自动给所有已打开的标签页注入 Content Script,无需刷新页面即可使用。

输入框保护

inputtextareaselect 以及 contentEditable 元素中,快捷键不会被拦截,确保正常输入不受影响。

使用方式

安装

  1. 下载项目:前往 GitHub 仓库,点击 Code → Download ZIP 并解压
  2. 打开 Chrome,进入 chrome://extensions/
  3. 开启右上角的 开发者模式
  4. 点击 加载已解压的扩展程序,选择解压后的文件夹
  5. 完成!在工具栏点击插件图标即可使用

使用

  1. 打开任意文章页面
  2. Space 键开始自动滚动(或点击插件面板的「开始滚动」按钮)
  3. 方向键实时调速
  4. 再按 Space 暂停,随时恢复

适用场景

  • 阅读掘金、知乎、Medium 等平台的长文章
  • 浏览技术文档和 API 文档
  • 看小说、新闻、公众号文章
  • 代码 Review 时自动滚动代码
  • 任何需要"解放双手"的阅读场景

写在最后

这个插件的代码非常简洁,总共就四个文件,零依赖,纯原生 JavaScript 实现。如果你也受够了空格键翻页和手动滚轮,不妨试试。

GitHub 地址:github.com/s2265681/au…

欢迎 Star、提 Issue、提 PR,也欢迎在评论区交流你的使用体验!


如果这篇文章对你有帮助,别忘了点个赞 👍

🍎用 pretext 搞定输入框动态宽度:一个困扰了我三天的 CSS 问题

pretext-cover.png

通过 @chenglou/pretext 库在前端精确计算文本宽度,实现了搜索表单输入框的动态宽度适配。告别 min-width 的粗暴限制,让 UI 更精致。

背景

做企业管理系统,搜索表单是最常见的组件。一个头疼的问题是:输入框宽度怎么定?

定死了,长文本挤成省略号;用 min-width,不同字段长度不同,结果参差不齐。

pretext1.png

直到我发现了 pretext 这个库。

问题场景

看这个搜索表单:

<!-- SearchForm/index.vue -->
<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            :style="getFieldStyle(field)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>

字段配置是动态的:

const fields = [
  { prop: 'name', label: '姓名', type: 'input' },
  { prop: 'idCard', label: '身份证号', type: 'input' },
  { prop: 'createTime', label: '创建时间', type: 'dateRange' },
  { prop: 'status', label: '状态', type: 'select', options: [...] }
]

字段有长有短:

  • "请输入姓名" → 短
  • "请输入身份证号码" → 长
  • "请选择开始时间 至 请选择结束时间" → 更长

核心问题:每个字段的 placeholder 长度不同,如何让输入框宽度刚刚好?

常见的"摆烂"方案

方案 1:固定宽度

.el-input {
  width: 200px; /* 要不挤死,要不太空 */
}

方案 2:min-width

.el-input {
  min-width: 180px;
  width: auto;
}

结果就是参差不齐——"姓名"和"身份证号"都是 200px,但明显应该不同宽度。

方案 3:后端返回宽度配置

每个字段配一个宽度值,后端告诉我该多宽。

工作量大,而且字段改了要同步改配置。

解决方案:pretext 文本测量

@chenglou/pretext 是一个纯 JS 的文本渲染库,能精确计算给定字体样式下文本的像素宽度。

核心原理:

  1. 传入文本 + 字体样式
  2. 返回每个字符的位置信息
  3. 由此计算出文本总宽度

安装

yarn add @chenglou/pretext

核心代码

// fieldStyle.js
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

// 字体样式,与 Element Plus el-input 保持一致
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

/**
 * 根据 placeholder 文本计算宽度
 * @param {string} placeholderText - 占位符文本
 * @param {number} extraPadding - 额外的内边距(默认 50px)
 * @returns {number} 计算后的宽度
 */
export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

/**
 * 获取字段宽度样式
 * @param {Object} field - 字段配置
 * @returns {Object} 宽度样式对象 { width: string }
 */
export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  // dateRange 类型需要更宽(显示两个日期 + 分隔符)
  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80; // dateRange 控件本身更宽
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

使用效果

字段 placeholder 计算宽度
姓名 请输入姓名 132px
身份证号 请输入身份证号 172px
创建时间 请选择开始时间至请选择结束时间 340px

输入框宽度自适应文本长度,视觉上整齐划一。

image.png

这样看是不是舒服多了!!!

踩坑记录

坑 1:字体必须完全一致

Element Plus 的 input 使用系统字体,如果 pretext 的字体定义和它不一致,计算出来的宽度会有偏差。

解决:直接从浏览器 DevTools 抄 Element Plus 的实际字体样式:

// Chrome DevTools Elements 面板
// 检查 .el-input__inner 的 computed styles
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

坑 2:dateRange 需要特殊处理

日期范围选择器显示的是两个日期 + "至"分隔符,宽度明显比普通输入框大。

解决:针对 dateRange 类型,把开始和结束的 placeholder 拼接起来计算,并且增大 extraPadding

坑 3:计算结果偏小

单独计算每个字段后,实际渲染还是有点挤。

原因:输入框还有 padding、border 等自身宽度。

解决:加了 extraPadding = 50px 的缓冲,不同类型调整这个值。

完整组件代码

utils/fieldStyle.js

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80;
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

components/SearchForm/index.vue

<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <!-- 输入框 -->
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            range-separator="至"
            :start-placeholder="field.startPlaceholder || `${field.label}开始时间`"
            :end-placeholder="field.endPlaceholder || `${field.label}结束时间`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 单个日期选择 -->
        <el-date-picker
            v-else-if="field.type === 'date'"
            v-model="formData[field.prop]"
            type="datetime"
            :placeholder="field.placeholder || `请选择${field.label}`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        >
          <el-option
              v-for="option in field.options"
              :key="option.value"
              :label="option.label"
              :value="option.value"
          />
        </el-select>
      </el-form-item>
      <el-form-item class="search-btn-group">
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="handleReset">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { ref, watch, reactive } from 'vue';
import { getFieldStyle } from '@/utils/fieldStyle';

const props = defineProps({
  modelValue: { type: Object, default: () => ({}) },
  fields: { type: Array, default: () => [] }
});

const emit = defineEmits(['update:modelValue', 'search', 'reset']);

const formData = reactive({ ...props.modelValue });

const initFormData = () => {
  props.fields.forEach(field => {
    if (!(field.prop in formData)) {
      formData[field.prop] = field.type === 'dateRange' ? [] : '';
    }
  });
};

watch(() => props.fields, () => { initFormData(); }, { immediate: true, deep: true });
watch(() => props.modelValue, (val) => { Object.assign(formData, val); }, { deep: true });

const handleSearch = () => {
  emit('search', { ...formData });
};

const handleReset = () => {
  props.fields.forEach(field => {
    formData[field.prop] = field.type === 'dateRange' ? [] : '';
  });
  emit('reset');
};
</script>

<style lang="scss" scoped>
.search-area {
  background: #fff;
  padding: 10px;
  margin-bottom: 0;
  border-radius: 4px;

  .search-form {
    .el-form-item {
      margin-right: 10px;
      margin-bottom: 12px;
    }
  }
}
</style>

对比效果

方案 姓名输入框 身份证号输入框 日期范围选择器
固定 200px 200px (空旷) 200px (刚好) 200px (挤)
min-width: 180px 180px (挤) 200px+ (不统一) 200px+ (不统一)
pretext 动态宽度 132px 172px 340px

适用场景

适合用 pretext 的场景:

  • 动态表单,字段配置来自后端
  • 多语言系统,不同语言文本长度差异大
  • 需要精细控制 UI 尺寸的企业级应用

不适合用的场景:

  • 固定的几 个字段,直接配固定宽度更简单
  • 性能敏感的热路径,pretext 计算有开销
  • 响应式布局,容器宽度本身就在变

总结

pretext 解决了文本宽度计算的难题,让输入框宽度能"自适应"文本长度。核心就两个 API:

const prepared = prepareWithSegments(text, fontStyle);
const result = layoutWithLines(prepared, maxWidth, fontSize);
result.lines[0].width; // 文本宽度

配上调优的 extraPadding,基本能覆盖大部分场景。


有问题欢迎留言交流。

你的 Vue 3 reactive(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中高频使用的 reactive()shallowReactive(),经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

  1. 示例只保留核心逻辑,省略完整组件包裹
  2. 你已熟悉 Vue 3 reactive / shallowReactive 用法

一、Vue reactive() → React useReactive()

reactive 是 Vue 3 最核心的对象响应式 API,在 VuReact 中会被精准映射。

基础编译对照

Vue 输入

<script setup>
  import { reactive } from 'vue';

  const state = reactive({
    count: 0,
    title: 'VuReact',
  });
</script>

VuReact 输出(React)

import { useReactive } from '@vureact/runtime-core';

const state = useReactive({
  count: 0,
  title: 'VuReact',
});

reactive 直接编译为 useReactive Hook:

  • 完全保留 Vue 响应式语义
  • 直接修改属性自动触发视图更新
  • 深层对象、数组、Map/Set 全部支持
  • 和 React 生命周期完美协同

TypeScript 场景:类型完整保留

Vue 输入(TS)

<script lang="ts" setup>
  import { reactive } from 'vue';

  interface User {
    id: number;
    name: string;
  }

  const state = reactive<{
    loading: boolean;
    users: User[];
    config: Record<string, any>;
  }>({
    loading: false,
    users: [],
    config: { theme: 'dark' },
  });
</script>

VuReact 输出(TS)

import { useReactive } from '@vureact/runtime-core';

interface User {
  id: number;
  name: string;
}

const state = useReactive<{
  loading: boolean;
  users: User[];
  config: Record<string, any>;
}>({
  loading: false,
  users: [],
  config: { theme: 'dark' },
});

接口、泛型、类型约束完全迁移
React 侧智能提示、类型检查全部正常
不用改一行类型逻辑


二、Vue shallowReactive() → React useShallowReactive()

浅层响应式用于性能优化,只监听顶层属性变化,VuReact 同样完美对齐。

基础编译对照

Vue 输入

<script setup>
  import { shallowReactive } from 'vue';

  const state = shallowReactive({
    nested: { count: 0 },
  });
</script>

VuReact 输出(React)

import { useShallowReactive } from '@vureact/runtime-core';

const state = useShallowReactive({
  nested: { count: 0 },
});

useShallowReactive 行为完全对齐 Vue:

  • 修改顶层属性 → 触发更新
  • 修改深层嵌套属性 → 不触发更新
  • 替换整个对象 → 触发更新
  • 适合大型列表、复杂状态、第三方数据等性能场景

总结一句话

  • Vue reactive → React useReactive
  • Vue shallowReactive → React useShallowReactive
  • 响应式行为一致
  • TypeScript 类型一致
  • 开发心智完全一致

用 VuReact,你可以:

  • 继续用 Vue 3 舒服的写法
  • 直接产出可维护的 React 代码
  • 无痛渐进迁移,不用一次性重构

🔗 相关资源

你的 Vue 3 ref(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的编译工具,并非运行时混合框架。今天我们直接看核心:Vue 高频使用的 ref() / shallowRef(),经过 VuReact 编译后会对应 React 的哪些代码?

前置约定

  1. 文中 Vue/React 代码均为核心逻辑简写,省略完整组件与冗余结构
  2. 你已熟悉 Vue 3 refshallowRef 的用法与行为

一、Vue ref() → React useVRef()

ref 是 Vue 3 最基础的响应式 API,在 VuReact 中会被直接编译为 React Hook。

基础编译对照

Vue 输入

<script setup>
  import { ref } from 'vue';
  const count = ref(0);
</script>

VuReact 输出(React)

import { useVRef } from '@vureact/runtime-core';
const count = useVRef(0);

ref 会被编译成 useVRef,它是 Vue ref 在 React 里的语义完全对齐的适配 API,保留 .value 访问与响应式更新行为。

带 TypeScript 类型场景

Vue 输入(TS)

<script lang="ts" setup>
  const title = ref<string>('');
  const isLoading = ref<boolean>(false);
  const userList = ref<Array<{ id: number; name: string }>>([]);
  const config = ref<Record<string, any>>({ theme: 'dark' });
</script>

VuReact 输出(TS)

const title = useVRef<string>('');
const isLoading = useVRef<boolean>(false);
const userList = useVRef<Array<{ id: number; name: string }>>([]);
const config = useVRef<Record<string, any>>({ theme: 'dark' });

TS 泛型、类型注解完整保留,React 侧类型提示完全可用。


二、Vue shallowRef() → React useShallowVRef()

shallowRef 是浅层响应式 API,只监听顶层引用变化,适合大对象性能优化。

基础编译对照

Vue 输入

<script setup>
  import { shallowRef } from 'vue';
  const count = shallowRef({ a: { b: 1, c: { d: 2 } } });
</script>

VuReact 输出(React)

import { useShallowVRef } from '@vureact/runtime-core';
const count = useShallowVRef({ a: { b: 1, c: { d: 2 } } });

useShallowVRef 完全对齐 shallowRef 行为:

  • 修改嵌套属性 → 不触发更新
  • 直接替换 .value触发更新

🔗 相关资源

Mapmost专题地图:解锁这场春游“热”

江苏首个春假+清明假期,苏州到底有多火?
文旅消费全省双料第一6天141处景区1200万人次——平均每秒钟就有23个人涌入苏州的公园、园林、古街和湖畔......走到哪里都是热闹的春日盛景。
跟着Mapmost,用一张张地图,解锁这场春游“热”!

赏樱路线

苏州热门赏樱地点众多。通过轨迹线功能,可以将每一个绝美打卡点串联起来,形成一条最全最优赏樱路线,不错过任何一处风景。

Mapmost赏樱图

园林热力

热门园林多次触发预警,借助热力图能力,可以实时呈现景区客流分布,帮助游客直观判断拥挤程度,合理筛选目的地,获得更舒适的游园体验。

Mapmost园林热力图

古城分区

山塘街、平江路等古城街区客流密集。Mapmost支持在地图上直接绘制多边形区域,清晰标出街区范围、边界及核心地址,让游客一眼看懂地理位置,用更短的路径逛遍更多古城精华。

Mapmost古城街区图

露营点位

太湖、阳澄湖等热门露营地,游客最关心的就是停车场和卫生间。Mapmost点标注功能,将这些设施在地图上标注出来,信息一目了然,避开其他信息干扰,愉快露营。

Mapmost露营点位图

结语

Mapmost来说,多样的地图叠加专题信息展示是基础能力,不仅可应用在智慧文旅,还可应用在智慧城市、智慧交通、智慧警务等多个领域。

Mapmost Studio提供了数据管理、服务发布、地图制图等功能,用户可自行发布数据、配制样式。Mapmost SDK for WebGL提供了多样的接口,用户可自行搭建系统。

立即开始Mapmost试用

👉 Mapmost在线体验地址**:**www.mapmost.com/#/productMa…

❌