普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月24日技术

How to Upgrade to Ubuntu 26.04

Ubuntu 26.04 LTS (Resolute Raccoon) was released on April 23, 2026. It ships with Linux kernel 7.0, GNOME 50, Python 3.14, PHP 8.5, Java 25, TPM-backed full-disk encryption, post-quantum cryptography support in OpenSSL, and a Wayland-only GNOME session. As an LTS release, it receives standard security updates for five years, with expanded coverage available through Ubuntu Pro.

This guide shows how to upgrade to Ubuntu 26.04 LTS from the command line. Ubuntu 25.10 systems can upgrade now, while Ubuntu 24.04 LTS systems will be offered the standard upgrade path after Ubuntu 26.04.1 is released.

Prerequisites

You need to be logged in as root or a user with sudo privileges to perform the upgrade.

You can upgrade to Ubuntu 26.04 from Ubuntu 25.10 today. Ubuntu 24.04 LTS systems will be offered the standard upgrade path after Ubuntu 26.04.1 is released. If you are running an older release, you must first upgrade to Ubuntu 22.04 and then to 24.04 before continuing.

Make sure you have a working internet connection before starting.

Back Up Your Data

Before starting a major version upgrade, make sure you have a complete backup of your data. If you are running Ubuntu on a virtual machine, take a full system snapshot so you can restore quickly if anything goes wrong.

Update Currently Installed Packages

Before starting the release upgrade, bring your existing system fully up to date.

Check whether any packages are marked as held back, as they can interfere with the upgrade:

Terminal
sudo apt-mark showhold

An empty output means there are no held packages. If there are held packages, unhold them with:

Terminal
sudo apt-mark unhold package_name

Refresh the package index and upgrade all installed packages:

Terminal
sudo apt update
sudo apt upgrade
Info
If the kernel is upgraded during this step, reboot the machine and log back in before continuing.

Perform a full distribution upgrade to resolve any remaining dependency changes:

Terminal
sudo apt full-upgrade

Remove automatically installed dependencies that are no longer needed:

Terminal
sudo apt --purge autoremove

Upgrade to Ubuntu 26.04 LTS

You can upgrade from the command line using do-release-upgrade, which works for both desktop and server installations.

do-release-upgrade is part of the update-manager-core package, which is installed by default on most Ubuntu systems. If it is not present, install it first:

Terminal
sudo apt install update-manager-core
Info
Check that the upgrade policy in /etc/update-manager/release-upgrades is set to Prompt=lts or Prompt=normal. If it is set to Prompt=never, the upgrade will not start.

If you are upgrading over SSH, do-release-upgrade may start an additional SSH daemon on port 1022 so you can reconnect if the main session drops. If you use a firewall, you may need to open that port temporarily:

Terminal
sudo ufw allow 1022/tcp

To begin the release upgrade, run:

Terminal
sudo do-release-upgrade

If Ubuntu does not offer the new release yet, follow the standard supported rollout for your current version. This guide covers the normal upgrade path.

The tool will disable third-party repositories, update the apt sources to point to the Ubuntu 26.04 repositories, and begin downloading the required packages.

You will be prompted several times during the process. When asked whether services should be automatically restarted, type y. When asked about configuration files, type Y to accept the package maintainer’s version if you have not made custom changes; otherwise keep your current version to avoid losing your customizations.

The upgrade runs inside a GNU screen session and will automatically re-attach if the connection drops.

The process may take some time depending on the number of packages, your hardware, and your internet speed.

Once the new packages are installed, the tool will ask whether to remove obsolete software. Type d to review the list first, or y to proceed with removal.

When the upgrade finishes, you will be prompted to restart the system. Type y to reboot and complete the upgrade.

Verify the Upgrade

After the system boots, log in and check the Ubuntu version :

Terminal
lsb_release -a
output
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 26.04 LTS
Release: 26.04
Codename: resolute

You can also confirm the kernel version:

Terminal
uname -r

The output should show a 7.0.x kernel.

Troubleshooting

Third-party repository errors during apt update
The upgrade tool disables third-party repositories automatically. If you see errors before running do-release-upgrade, disable the affected sources under /etc/apt/sources.list.d/ and re-enable them one by one after the upgrade completes.

“Packages have been kept back”
Run sudo apt full-upgrade to resolve held-back packages before starting the upgrade. Do not proceed with do-release-upgrade until there are no held packages.

SSH connection drops mid-upgrade
The upgrade runs inside a GNU screen session. Reconnect over SSH and run sudo screen -r to re-attach to the running session.

A service or container does not work after the reboot
If something that worked before the upgrade fails afterward, check its status and logs first. Run systemctl status service_name and journalctl -xe for services, and review your container runtime logs and configuration for Docker or containerd workloads. Third-party packages and older configurations sometimes need updates after a major release upgrade.

GNOME desktop shows only Wayland sessions
Ubuntu 26.04 drops the X11 GNOME session. If you relied on an X11 desktop session, applications that do not support Wayland natively will run through XWayland automatically. For most applications this is transparent, but some older tools may behave differently.

Conclusion

Your system is now running Ubuntu 26.04 LTS Resolute Raccoon. Re-enable any third-party repositories you disabled before the upgrade and verify that your critical services are running. For a full list of known issues and changes, see the official Ubuntu 26.04 release notes .

贪心

2023年8月27日 14:09

记录三种字符的个数,计算出L和R的差值的绝对值,最后加上'_'的数量,直接返回。

class Solution {
public:
    int furthestDistanceFromOrigin(string moves) {
        int cnt1 = 0, cnt2 = 0, cnt3 = 0;
        for (auto x : moves) {
            if (x == 'L') cnt1++;
            if (x == 'R') cnt2++;
            if (x =='_') cnt3++;
        }
        return cnt1 > cnt2 ? cnt1 + cnt3 - cnt2 : cnt2 + cnt3 - cnt1;
    }
};
class Solution {
    public int furthestDistanceFromOrigin(String moves) {
        int cnt1 = 0, cnt2 = 0, cnt3 = 0;
        char[] str = moves.toCharArray();
        for (char x : str) {
            if (x == 'L') cnt1++;
            if (x == 'R') cnt2++;
            if (x =='_') cnt3++;
        }
        return cnt1 > cnt2 ? cnt1 + cnt3 - cnt2 : cnt2 + cnt3 - cnt1;
    }
}
func furthestDistanceFromOrigin(moves string) int {
    cnt1, cnt2, cnt3 := 0, 0, 0
    for i := 0; i < len(moves); i++ {
        if moves[i] == 'L' {
            cnt1++
        }
        if moves[i] == 'R' {
            cnt2++
        } 
        if moves[i] =='_' {
            cnt3++
        }
    }
    if (cnt1 > cnt2) {
        return cnt1 + cnt3 - cnt2
    }
    return cnt2 + cnt3 - cnt1

}

每日一题-距离原点最远的点🟢

2026年4月24日 00:00

给你一个长度为 n 的字符串 moves ,该字符串仅由字符 'L''R''_' 组成。字符串表示你在一条原点为 0 的数轴上的若干次移动。

你的初始位置就在原点(0),第 i 次移动过程中,你可以根据对应字符选择移动方向:

  • 如果 moves[i] = 'L'moves[i] = '_' ,可以选择向左移动一个单位距离
  • 如果 moves[i] = 'R'moves[i] = '_' ,可以选择向右移动一个单位距离

移动 n 次之后,请你找出可以到达的距离原点 最远 的点,并返回 从原点到这一点的距离

 

示例 1:

输入:moves = "L_RL__R"
输出:3
解释:可以到达的距离原点 0 最远的点是 -3 ,移动的序列为 "LLRLLLR" 。

示例 2:

输入:moves = "_R__LL_"
输出:5
解释:可以到达的距离原点 0 最远的点是 -5 ,移动的序列为 "LRLLLLL" 。

示例 3:

输入:moves = "_______"
输出:7
解释:可以到达的距离原点 0 最远的点是 7 ,移动的序列为 "RRRRRRR" 。

 

提示:

  • 1 <= moves.length == n <= 50
  • moves 仅由字符 'L''R''_' 组成

【统计墙头草】差额合并

作者 l00
2023年8月28日 11:07

2833. 距离原点最远的点 - 第 360 场周赛

思路

“_”就是墙头草,合并前先统计其总量,L和R谁赢帮谁

###python

class Solution:
    def furthestDistanceFromOrigin(self, moves: str) -> int:
        l = r = d = 0
        for move in moves:
            if move == 'L': l += 1
            elif move == 'R': r += 1
            else: d += 1
        return abs(l - r) + d

###java

class Solution {
    public int furthestDistanceFromOrigin(String moves) {
        int lr = 0, d = 0;
        for (char ch : moves.toCharArray()) {
            if (ch == '_') d++;
            else lr += (ch & 2) - 1;
        }
        return Math.abs(lr) + d;
    }
}

image.png

贪心

作者 tsreaper
2023年8月27日 13:19

解法:贪心

题目稍微有点不清晰,其实问的是完成 $n$ 次移动之后的那个终点距离原点最远有多远,并不考虑经过的中间点。

终点要么尽量在左边,要么尽量在右边。所以 _ 要么都改成 L,要么都改成 R。取两种情况的最大值即可。复杂度 $\mathcal{O}(n)$。

参考代码(c++)

###c++

class Solution {
public:
    int furthestDistanceFromOrigin(string moves) {
        // 求字符串 s 的终点到原点的距离
        auto gao = [&](string s) {
            int d = 0;
            for (char c : s) {
                if (c == 'L') d--;
                else d++;
            }
            return abs(d);
        };

        // 所有 _ 都改成 L
        string L = moves;
        for (char &c : L) if (c == '_') c = 'L';
        // 所有 _ 都改成 R
        string R = moves;
        for (char &c : R) if (c == '_') c = 'R';
        // 取两种情况的最大值
        return max(gao(L), gao(R));
    }
};

贪心(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2023年8月27日 12:15

示例 2 的 $\textit{moves} = \texttt{_R__LL_}$:

  • 先只看 $\texttt{R}$ 和 $\texttt{L}$,往右走了 $1$ 步,再往左走 $2$ 步,相当于往左走了 $1$ 步,位于数轴的 $-1$。
  • 还剩下 $4$ 个 $\texttt{_}$,怎么走最优?当然是继续往左走啦,继续往左走 $4$ 步,最终位于 $-5$。

设 $\textit{cntR}$ 为 $\texttt{R}$ 的个数,$\textit{cntL}$ 为 $\texttt{L}$ 的个数,那么 $\texttt{_}$ 的个数为 $n - \textit{cntR} - \textit{cntL}$。先只看 $\texttt{R}$ 和 $\texttt{L}$,我们到原点的距离为 $|\textit{cntR} - \textit{cntL}|$。然后继续走 $n - \textit{cntR} - \textit{cntL}$ 步,最终答案为

$$
|\textit{cntR} - \textit{cntL}| + n - \textit{cntR} - \textit{cntL}
$$

class Solution:
    def furthestDistanceFromOrigin(self, moves: str) -> int:
        cnt_r = moves.count('R')
        cnt_l = moves.count('L')
        return abs(cnt_r - cnt_l) + len(moves) - cnt_r - cnt_l
class Solution {
    public int furthestDistanceFromOrigin(String moves) {
        int cntR = 0;
        int cntL = 0;
        for (char c : moves.toCharArray()) {
            if (c == 'R') {
                cntR++;
            } else if (c == 'L') {
                cntL++;
            }
        }
        return Math.abs(cntR - cntL) + moves.length() - cntR - cntL;
    }
}
class Solution {
public:
    int furthestDistanceFromOrigin(string moves) {
        int cnt_r = ranges::count(moves, 'R');
        int cnt_l = ranges::count(moves, 'L');
        return abs(cnt_r - cnt_l) + moves.size() - cnt_r - cnt_l;
    }
};
int furthestDistanceFromOrigin(char* moves) {
    int cnt_r = 0;
    int cnt_l = 0;
    int i = 0;
    for (; moves[i]; i++) {
        if (moves[i] == 'R') {
            cnt_r++;
        } else if (moves[i] == 'L') {
            cnt_l++;
        }
    }
    return abs(cnt_r - cnt_l) + i - cnt_r - cnt_l;
}
func furthestDistanceFromOrigin(moves string) int {
cntR := strings.Count(moves, "R")
cntL := strings.Count(moves, "L")
return abs(cntR-cntL) + len(moves) - cntR - cntL
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}
var furthestDistanceFromOrigin = function(moves) {
    const cntR = [...moves].filter(c => c === 'R').length;
    const cntL = [...moves].filter(c => c === 'L').length;
    return Math.abs(cntR - cntL) + moves.length - cntR - cntL;
};
impl Solution {
    pub fn furthest_distance_from_origin(moves: String) -> i32 {
        let cnt_r = moves.bytes().filter(|&c| c == b'R').count() as i32;
        let cnt_l = moves.bytes().filter(|&c| c == b'L').count() as i32;
        (cnt_r - cnt_l).abs() + moves.len() as i32 - cnt_r - cnt_l
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{moves}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天 — 2026年4月23日技术

手把手带你实现一个 mini-claude-code

2026年4月23日 19:48

从零到一,用 10 步构建一个面向 Coding Agent 的 CLI 可观测运行时,项目地址:weak-claw,欢迎各位读者点Star⭐。


写在前面

你有没有好奇过 Claude Code、Cursor Agent、Copilot Workspace 这些 AI 编程工具背后的 Agent Runtime 是怎么实现的?

  • 它怎么管理上下文,让 100+ 步的长任务不崩?
  • 工具调用震荡(反复读目录→读文件→再读目录)怎么防?
  • 日志、指标、代码审查这些旁路逻辑怎么不侵入主流程?

如果你也有这些疑问,这个项目就是为你准备的。

Myclaw 是一个面向 Coding Agent 的 CLI 可观测运行时,技术栈为 TypeScript + Node.js + Oclif + OpenAI SDK + EventBus。我把整个实现过程拆成了 10 个递进式步骤,每一步都有完整的代码和配套学习文档,帮你从"调通一个 API"走到"构建一个工程化的 Agent 系统"。


这个项目解决什么问题?

在多轮 Coding Agent 任务中,三个核心痛点几乎不可避免:

痛点 表现 后果
上下文膨胀 Agent 多轮工具调用快速打满上下文窗口 早期关键信息丢失,任务失败
工具调用震荡 陷入"读目录→读文件→再读目录"死循环 Token 白白消耗,无实质产出
运行时与监控强耦合 日志、指标散落在业务逻辑各处 每次加监控都要改核心代码,迭代成本高

围绕 稳定性与可观测性 两个目标,本项目落地了三大核心能力:

1. 多级上下文管理

构建"跨会话长期记忆 + 压缩摘要块(Summary Blocks) + 滑动窗口"分层记忆架构:

┌─────────────────────────────────────────┐
  Layer 1: 全量消息 (session.messages)       完整保留,不删除
├─────────────────────────────────────────┤
  Layer 2: 压缩摘要 (Summary Blocks)         20 条消息批量压缩
├─────────────────────────────────────────┤
  Layer 3: 滑动窗口 (最近 20 条)             实际发给模型的上下文
└─────────────────────────────────────────┘

通过路径索引实现无损压缩与按需回溯,兼顾 Token 成本与长任务逻辑连续性。压缩比约 20:1,长任务从"20 步超限崩溃"变成"100+ 步稳定运行"。

2. 异步代码审查闭环

设计写后异步代码审查旁路——Agent 写完代码后,后台自动跑语法检查和 ESLint,失败结果在下一个循环步骤自动注入,触发模型自修复:

Agent 写文件 → write_completed 事件
    ↓
EslintCheckSubscriber(后台异步)
    ├── Node.js 语法检查 (node --check)
    ├── Python 语法检查 (python3 -m py_compile)
    └── ESLint 软门禁 (npx eslint)
    ↓ 失败
CheckGate(全局消费式队列)
    ↓ Agent 循环下一步 popFailures()
注入 tool_result → 模型自动修复

不阻塞主流程,审查异常不打断 Agent 执行。

3. 运行时与监控解耦

引入 EventBus + Subscriber 模型,Agent 循环只负责发射事件,所有旁路逻辑(日志、指标、代码审查、用户档案提取)通过 Subscriber 订阅处理:

Agent 循环 → emitEvent() → EventBus
                              ↓
          ┌──────────────┬──────────────┬──────────────┐
          │ SessionLog   │ Metrics      │ EslintCheck  │
          │ (JSONL 日志)  │ (运行指标)    │ (代码审查)    │
          └──────────────┴──────────────┴──────────────┘

新增监控需求?写一个 Subscriber 即可,零侵入核心逻辑。


项目架构全景

                            ┌────────────────────┐
                            │   CLI Layer         │
                            │  (Oclif Commands)   │
                            │  chat / run / hello │
                            └────────┬───────────┘
                                     │
                            ┌────────▼───────────┐
                            │   Config Layer      │
                            │  Zod Schema         │
                            │  + cosmiconfig      │
                            │  + dotenv           │
                            └────────┬───────────┘
                                     │
              ┌──────────────────────▼──────────────────────┐
              │              Agent Core (agent.ts)           │
              │                                              │
              │  ┌──────────┐  ┌───────────┐  ┌──────────┐ │
              │  │ Session   │  │  ReAct    │  │ Context  │ │
              │  │ Manager   │  │  Loop     │  │ Manager  │ │
              │  └──────────┘  └───────────┘  └──────────┘ │
              │                                              │
              │  ┌──────────┐  ┌───────────┐  ┌──────────┐ │
              │  │ Tool      │  │ JSON      │  │ Safety   │ │
              │  │ Executor  │  │ Fallback  │  │ Guard    │ │
              │  └──────────┘  └───────────┘  └──────────┘ │
              └──────────┬─────────────────────┬───────────┘
                         │                     │
              ┌──────────▼──────┐   ┌──────────▼──────┐
              │  Provider Layer │   │  EventBus       │
              │  Mock / OpenAI  │   │  (publish)      │
              └─────────────────┘   └────────┬────────┘
                                             │
                    ┌────────────────────────┬┴───────────────┐
                    │                        │                │
           ┌───────▼───────┐  ┌─────────▼──────┐  ┌─────▼────────┐
           │ SessionLog    │  │  Metrics       │  │ EslintCheck  │
           │ Subscriber    │  │  Subscriber    │  │ Subscriber   │
           │ (JSONL 日志)   │  │  (运行指标)     │  │ (代码审查)    │
           └───────────────┘  └────────────────┘  └──────────────┘

10 步学习路线

本项目采用 递进式构建 —— 每一步在前一步基础上增量添加新能力,最终拼合成完整系统。

步骤 主题 学习文档 关键知识点
1 项目脚手架 + 类型定义 + Mock Provider 01-scaffolding.md Oclif CLI 框架、LLMProvider 接口抽象、三层配置系统(Zod + cosmiconfig + dotenv)
2 最简 Agent 循环(单轮,无工具) 02-basic-agent-loop.md 会话管理(InMemorySessionStore)、系统提示词构建、Agent 单轮执行流程
3 工具定义与执行 03-tools.md JSON Schema 工具定义、6 个工具实现(read/write/patch/list/search/shell)、路径安全验证
4 多轮 ReAct 循环 + 工具调用链 04-multi-turn-tools.md ReAct 循环(for step < maxSteps)、JSON Fallback 三级降级解析、振荡检测(repeatRatio/noveltyRatio)
5 EventBus + 基础 Subscriber 05-eventbus.md 发布/订阅模式、AgentEvent 联合类型(15+ 种事件)、Subscriber 异常隔离、Promise 链写入
6 会话持久化与恢复 06-session-persistence.md JSONL 追加写入、两轮遍历状态重建、readPaths/compressedCount 精确恢复
7 上下文管理(滑动窗口 + 压缩摘要) 07-context-management.md 三级分层记忆、孤立 tool 消息裁剪、压缩触发策略、路径索引
8 异步代码审查闭环 08-check-gate.md CheckGate 消费式队列、EslintCheckSubscriber 三类检查、异步非阻塞设计
9 用户档案系统 09-user-profile.md 被动信号提取、跨会话持久化、system prompt 融入
10 OpenAI Provider + 完整 CLI 10-complete-cli.md OpenAI SDK 接入、超时+重试+取消机制、交互式 readline Chat 命令

两种学习方式

方式一:跟着代码动手做

  • 克隆仓库,从 Step 1 开始,对照每一步的文档和源代码逐步实现
  • 每一步都可编译运行验证,确保理解后再进入下一步
  • 适合想深入理解每行代码的同学

方式二:只看文档快速了解

  • 直接阅读 docs/ 目录下的 10 篇学习文档
  • 每篇文档包含:本步目标、新增文件说明、核心概念、关键代码解读、设计决策分析
  • 适合想快速掌握 Agent 系统架构思想的同学

技术栈

技术 作用
TypeScript 类型安全的开发语言
Node.js 运行时环境
Oclif CLI 框架,命令自动发现与注册
OpenAI SDK LLM 调用(兼容 OpenAI API 格式的后端均可使用)
Zod 配置 Schema 校验
cosmiconfig 多来源配置加载
EventBus(自研) 事件驱动架构,~50 行代码,轻量可控

快速开始

# 1. 克隆仓库
git clone https://github.com/<your-username>/weak-claw.git
cd weak-claw

# 2. 安装依赖
npm install

# 3. 编译
npm run build

# 4. Mock 模式体验(无需 API Key)
MYCLAW_PROVIDER=mock node ./bin/dev.js run "用 TypeScript 写一个 hello world"

# 5. 交互式聊天(Mock 模式)
MYCLAW_PROVIDER=mock node ./bin/dev.js chat

# 6. 接入真实模型(需要 OpenAI API Key)
cp .env.example .env
# 编辑 .env 填入 OPENAI_API_KEY
node ./bin/dev.js chat

学完之后你能收获什么?

工程能力提升

  • Agent 系统全链路理解:从 CLI 入口 → 配置加载 → 会话管理 → ReAct 循环 → 工具执行 → 上下文管理 → 事件驱动,掌握 Coding Agent 系统的完整工程架构
  • 设计模式实战:Provider 工厂模式、发布/订阅模式、策略模式(上下文压缩)、消费式队列、Promise 链顺序写入等,每个模式都有真实场景驱动
  • 防御性编程:路径安全验证、写前必读机制、循环兜底、振荡检测、监控异常隔离——这些都是生产级 Agent 系统必须考虑的问题

知识体系构建

  • 上下文管理:理解为什么简单的"截断"不够用,分层记忆架构如何在 Token 成本和信息完整性之间取得平衡
  • 可观测性工程:EventBus + Subscriber 如何实现"加监控不改业务代码",以及 JSONL 日志为什么比数据库更适合 CLI 场景
  • LLM 工程化:JSON Fallback 解析、多模型兼容、超时重试取消——这些是 LLM 应用从 demo 到生产的关键差距

面试加分项

项目中附带了一份详细的 面试准备指南,包含:

  • 2-3 分钟项目介绍话术
  • 三大核心能力的深入展开
  • 4 个真实技术难点与解决方案
  • 8 个高频面试追问及参考回答
  • 项目架构全景图(白板讲解用)

面试项目介绍(精简版)

Myclaw 是一个面向 Coding Agent 的 CLI 可观测运行时。

做这个项目的背景是:在多轮 Coding Agent 任务中,我发现三个核心痛点——上下文膨胀、工具调用震荡、运行时与监控强耦合。

针对这三个问题,我落地了三大核心能力:

  1. 多级上下文管理:构建"全量消息 + 压缩摘要块 + 滑动窗口"三级分层架构,压缩比 20:1,长任务从 20 步崩溃到 100+ 步稳定运行
  2. 异步代码审查闭环:写后自动触发语法/lint 检查,失败结果通过消费式队列注入 Agent 循环,触发模型自修复,全程异步不阻塞
  3. EventBus 解耦:Agent 只管发射事件,日志/指标/审查/档案都通过 Subscriber 订阅,新增监控零侵入核心逻辑

技术栈是 TypeScript + Node.js + Oclif + OpenAI SDK + 自研 EventBus,核心代码约 3000 行。

更多面试细节请查看 面试准备指南


项目结构

src/
├── commands/           # CLI 命令
│   ├── chat.ts         # 交互式多轮对话
│   ├── run.ts          # 一次性任务执行
│   └── hello.ts        # 测试命令
├── config/             # 配置系统
│   ├── schema.ts       # Zod Schema 定义
│   ├── load-config.ts  # 三层配置加载
│   └── paths.ts        # 路径管理
├── core/               # Agent 核心
│   ├── agent.ts        # 会话管理 + ReAct 循环 + 上下文管理(~1300 行)
│   ├── event-bus.ts    # EventBus 实现
│   ├── session-store.ts# 内存会话存储
│   ├── check-gate.ts   # 审查消费式队列
│   ├── user-profile.ts # 用户档案读写
│   └── subscribers/    # 4 个 Subscriber
│       ├── session-log-subscriber.ts
│       ├── metrics-subscriber.ts
│       ├── eslint-check-subscriber.ts
│       └── user-profile-subscriber.ts
├── providers/          # LLM Provider 抽象
│   ├── types.ts        # 接口定义
│   ├── mock-provider.ts# Mock(开发测试)
│   └── openai-provider.ts # OpenAI(生产)
└── tools/              # 工具实现
    ├── filesystem.ts   # 文件操作(read/write/patch/list/search)
    └── shell.ts        # Shell 命令执行

前端仔速通 Python

作者 醒来明月
2026年4月23日 17:55

前言

对于很多已经熟悉 JavaScript / Node.js 的开发者来说,学习 Python 往往不是“从零开始”,而是一次语言迁移。你已经掌握了编程的核心能力:如何拆解问题、如何设计数据结构、如何写函数和模块、如何处理异步逻辑、如何组织项目结构。所以学习 Python,真正需要适应的,其实只有两件事:

第一,语法表达方式不同。
第二,Python 社区有自己的一套开发习惯。

这篇文章是站在 JavaScript 开发者的视角,帮助你快速理解 Python 到底该怎么学、怎么写、怎么用。

一、先理解本质

很多人第一次看到 Python,会觉得它和 JavaScript 差别巨大,比如:

// JavaScript
for (let i = 0; i < 5; i++) {
  console.log(i)
}
# Python
for i in range(5):
    print(i)

看起来像两种语言,但本质上它们表达的是同一件事:创建变量 i,循环执行 5 次并输出结果。区别只是:Python 不写花括号、用缩进表示代码块、更强调可读性。如果用一句话总结:JavaScript 更灵活而 Python 更克制。

二、变量与数据类型:概念相同,命名不同

// JavaScript 写法
let name = "Tom"
let age = 18
let price = 9.9
let isAdmin = true
let tags = ["js", "python"]
let user = { name: "Tom", age: 18 }
# Python 写法
name = "Tom"
age = 18
price = 9.9
is_admin = True
tags = ["js", "python"]
user = {"name": "Tom", "age": 18}
JavaScript Python
string str
number int / float
boolean bool
array list
object dict
null None

你需要适应的地方

1. 布尔值大小写不同

True False None
true false null

2. 命名风格不同

# JavaScript 常用:
userName
getUserInfo

# Python 主流写法:
user_name
get_user_info

这叫 snake_case(蛇形命名) ,是 Python 社区默认规范。

三、条件判断:去掉括号和花括号

# JavaScript
if (age >= 18) {
  console.log("成年")
} else if (age >= 12) {
  console.log("青少年")
} else {
  console.log("儿童")
}
# Python
if age >= 18:
    print("成年")
elif age >= 12:
    print("青少年")
else:
    print("儿童")

最大变化有三个:1. 不需要括号、2. 使用冒号开始代码块、3. 使用缩进表示层级。这也是 Python 最核心的语法特征之一。

四、循环:Python 更像自然语言

自然计数循环

// JavaScript
for (let i = 0; i < 5; i++) {
  console.log(i)
}
# Python
for i in range(5):
    print(i)

遍历数组

// JavaScript
for (const item of arr) {
  console.log(item)
}
# Python
for item in arr:
    print(item)

while 循环

// JavaScript
let i = 0
while (i < 5) {
  i++
}
# Python
i = 0
while i < 5:
    i += 1

注意:Python 没有 ++

# 错误写法:
i++

# 正确写法:
i += 1

五、函数:写法更干净

// JavaScript
function add(a, b) {
  return a + b
}
// 或者
const add = (a, b) => a + b
# Python
def add(a, b):
    return a + b
# 或者
add = lambda a, b: a + b

Python 也有简写函数(lambda),但实际开发里,大多数场景仍然推荐正常使用 def。因为更清晰,也更符合 Python 风格。


六、字符串处理

// JavaScript
const name = "Tom"
console.log(`Hello ${name}`)
# Python
name = "Tom"
print(f"Hello {name}")

Python 的叫:f-string,非常常用,也是 Python 字符串格式化的首选方式。

七、数组 map / filter 在 Python 怎么写

// JavaScript map
arr.map(x => x * 2)

// JavaScript filter
arr.filter(x => x > 2)
# Python 推荐写法
arr = [1, 2, 3]
result = [x * 2 for x in arr]
# 筛选
result = [x for x in arr if x > 2]

为什么 Python 喜欢这样写?因为这叫:列表推导式(List Comprehension),它兼顾:简洁、可读性、性能,在 Python 中非常常见。

八、对象在 Python 里叫 dict

// JavaScript
const user = {
  name: "Tom",
  age: 18
}
// 读取
user.name
# Python
user = {
    "name": "Tom",
    "age": 18
}
# 读取:
user["name"]
# 修改:
user["age"] = 20

注意区别,JavaScript 对象很多时候既是数据结构,也是实例对象。

Python 更明确区分:字典(dict)是键值数据结构,类实例(class object)是对象。

九、类与 this:Python 用 self

// JavaScript
class User {
  constructor(name) {
    this.name = name
  }

  say() {
    console.log(this.name)
  }
}
# Python
class User:
    def __init__(self, name):
        self.name = name

    def say(self):
        print(self.name)
        
u = User("Tom")
u.say()

理解 self

你可以把它理解成:Python 把 JavaScript 隐式的 this,变成了显式参数 self。这样做的好处是更直观,也更明确。

十、模块系统:import / export 与 Python 的区别

JavaScript 模块(ES Module)

// 导出
export const name = "Tom"

export function add(a, b) {
  return a + b
}

// 导入
import { name, add } from "./utils.js"

Python 模块系统

Python 没有 export 关键字,一个 .py 文件天然就是一个模块。

# 比如你有一个 utils.py 文件,内容:
name = "Tom"
def add(a, b):
    return a + b
    
    
# 导入模块
import utils
print(utils.name)
print(utils.add(1, 2))

# 解构导入(类似 JS)
from utils import name, add
print(name)
print(add(1, 2))

起别名

import utils as u

from utils import add as plus

对比总结

JavaScript Python
export 默认公开
import xxx from import xxx
import { a } from from xxx import a
import * from xxx import *(不推荐)

Python 为什么没有 export?

因为 Python 默认认为模块中的变量和函数都可以被导入。如果你不想被导入,通常约定写成_internal_var的形式,下划线表示“内部使用”。

十一、文件操作

//  JavaScript
const fs = require("fs")
const text = fs.readFileSync("a.txt", "utf8")
# Python
with open("a.txt", "r", encoding="utf-8") as f:
    text = f.read()

print(text)

with open()会自动关闭文件资源。这是 Python 非常经典的写法。


十二、异常处理

// JavaScript
try {
  run()
} catch (e) {
  console.log(e)
}
# Python
try:
    run()
except Exception as e:
    print(e)

# 多种异常捕获
try:
    pass
except ValueError:
    pass
except TypeError:
    pass

十三、包管理:npm 对应 pip

# JavaScript
npm install axios

# Python
pip install requests

项目依赖文件

JavaScript:package.json

Python 常见:requirements.txtpyproject.toml

十四、如果你是 Node.js 开发者,Python 后端怎么选?

Node.js Python
Express Flask
NestJS FastAPI
Fullstack Framework Django

如果你追求现代开发体验,建议优先学:FastAPI,因为它对 JS 开发者非常友好。

十五、总结

如果你会 JavaScript,学习 Python 并不是重新学编程。你只是在学习另一种更简洁、更稳定、更适合工程和工具开发的表达方式。JavaScript 像一个灵活的年轻人,Python 像一个经验丰富的老工程师,它们并不冲突。

前端从0开始的LangChain学习(一)

作者 3800
2026年4月23日 17:44

LangChain是什么,干什么的

LangChain是AI Agent 开发的一个框架。可以理解为:Lang + Chain = 语言模型 + 链式调用。它支持几乎所有的大模型(deepseek、chatGpt等),并且提供了统一的调用方式,让你轻松切换不同的模型

所以可以通过 LangChain 这个框架去开发AI Agent,方式是通过链式调用,把大模型(LLM)能力连接到实际应用中

为什么需要LangChain

传统的AI对话开发:核心在于后端业务逻辑、数据库设计、API 接口等技术栈。

而AI Agent开发:核心变成了如何与大模型对话、如何优化提示词、如何管理对话流程

而Langchain就可以做到

对于前端来说难吗

LangChain 原生支持 TypeScript!这意味着前端开发者可以用自己熟悉的 JavaScript/TypeScript 来构建 AI 应用。

LangChain的6个核心

Models模型接入

首先安装一下环境

pnpm i @langchain/core @langchain/openai dotenv langchain zod

然后根目录创建.env文件

DEEPSEEK_API_KEY="你的apikey,这里我用的deepSeek"

LangChain提供了统一的大模型接口

import { HumanMessage } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from "dotenv";

dotenv.config();

// DeepSeek 配置(兼容 OpenAI 接口)
const deepseekModel = new ChatOpenAI({ 
  model: "deepseek-chat", 
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: 'https://api.deepseek.com/v1'
  }
});

// 也可以使用其他模型
// const openaiModel = new ChatOpenAI({ model: "gpt-4", apiKey: process.env.OPENAI_API_KEY });
// const claudeModel = new ChatAnthropic({ model: "claude-3-opus-20240229", apiKey: process.env.ANTHROPIC_API_KEY });

// 统一调用方式
const messages = [new HumanMessage("你好,今天成都天气怎么样")];
const response = await deepseekModel.invoke(messages);
console.log(response.content);

Prompts提示词管理

基础模板

PromptTemplate.fromTemplate

/**
 * 演示langchain创建prompt提示词
 */
import { PromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from "dotenv";

dotenv.config();

// 创建提示词模板
const promptModel = PromptTemplate.fromTemplate(`
  你是一个{role},
  请用不超过{limit}个字符回答以下问题:
  {question}  
`);
// 根据模板,填充提示词
const createPrompt = await promptModel.format({
  role: '专业翻译',
  limit: 10,
  question: '你好请翻译,我想知道你是男是女。',
});
// 初始化deepseek模型
const model = new ChatOpenAI({
  model: 'deepseek-reasoner',
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: process.env.DEEPSEEK_API_URL,
  }
});
// 调用模型
const response = await model.invoke(createPrompt);
console.log(response.content);

多消息模板

/**
 * 演示langchain创建多消息prompt提示词
 */
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from "dotenv";

dotenv.config();

// 创建提示词模板
const chatPrompt = ChatPromptTemplate.fromMessages([
  { role: 'system', content: '你是一个专业的{role},请用{language}回答。' },
  { role: 'human', content: '{input}' },
]);
// 根据模板,填充提示词
const createPrompt = await chatPrompt.formatMessages({
  role: 'AI应用开发专家',
  language: '中文',
  input: '如何使用DeepSeekAPI',
});
// 初始化deepseek模型
const model = new ChatOpenAI({
  model: 'deepseek-reasoner',
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: process.env.DEEPSEEK_API_URL,
  },
});
// 调用模型
const response = await model.invoke(createPrompt);
console.log(response.content);

占位符模板(记忆)

const promptWithMemory = ChatPromptTemplate.fromMessages([
  ["system", "你是一个友好的 AI 助手,由深度求索公司开发"],
  new MessagesPlaceholder("chat_history"),  // 记忆会动态插入到这里
  ["human", "{input}"]
]);

Chains:流程编排

LangChain 表达式语言 (LCEL, LangChain Expression Language) 是最推荐的方式(类似于promise),它用 | 管道符将组件串联起来:

import { StringOutputParser } from "@langchain/core/output_parsers";
import { PromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from 'dotenv';
dotenv.config();

// 配置 DeepSeek 模型
const model = new ChatOpenAI({ 
  model: "deepseek-chat", 
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: process.env.DEEPSEEK_API_URL,
  }
});

const prompt = PromptTemplate.fromTemplate("给我讲一个关于{topic}的笑话,要简短有趣");

// 使用 LCEL 语法构建链
const chain = prompt.pipe(model).pipe(new StringOutputParser());

// 执行链
const result = await chain.invoke({ topic: "程序员" });
console.log(result);

// 我们也可以串联更复杂的操作
const complexChain = prompt
  .pipe(model)
  .pipe(new StringOutputParser())
  .pipe((text) => `🤖 AI 说:${text}`); // 自定义处理

const upperResult = await complexChain.invoke({ topic: "AI" });
console.log(upperResult);

通过 pipe 方法串联。

Tools工具

工具是 Agent 的“手脚”,可以拓展 Agent 的功能。LangChain 提供了多种定义工具的方式:

一个 LangChain 工具包含三个核心要素:

  • name:工具名称,AI通过它选择工具
  • description:工具描述,AI判断何时使用
  • func:实际执行的函数

字符串输入

import { DynamicTool } from "@langchain/core/tools";

const simpleTool = new DynamicTool({
  name: "get_time",
  description: "获取当前时间",
  func: async (input) => {
    if (input) {
        return '不告诉你'
    }
    return new Date().toLocaleString();
  }
});

// 使用
const result = await simpleTool.func("当前时间");
console.log("当前时间:", result); //'不告诉你'

验证参数类型(推荐)

import { DynamicStructuredTool } from "langchain";
import z from "zod";

const weatherTool = new DynamicStructuredTool({
    name: 'weather',
    description: '查询天气信息,输入城市名称',
    schema: z.object({
        city: z.string().describe('城市名称'),
        unit: z.enum(['celsius', 'fahrenheit']).optional().describe('温度单位')
    }),
    func: async ({ city, unit = 'celsius' }) => {
        // 模拟天气数据
        const weatherData = {
            "北京": { temp: 22, condition: "晴" },
            "上海": { temp: 18, condition: "雨" },
            "武汉": { temp: 25, condition: "阴" }
        };

        const data = weatherData[city];
        if (!data) {
            return `未找到城市 "${city}" 的天气信息`;
        }

        const temp = unit === "celsius" ? `${data.temp}°C` : `${data.temp * 9 / 5 + 32}°F`;
        return `${city}今天${data.condition},温度${temp}`;
    }
})

const res = await weatherTool.func({ city: '北京' });
console.log(res);
// 北京今天晴,温度22°C

其中 zod 库可以帮我们验证参数类型,也能通过 describe 为模型提供参数说明。工具的 description 直接决定 AI 调用工具的准确率

zod schema 详解

基础类型
const basicSchema = z.object({
  name: z.string(),           // 字符串
  age: z.number(),            // 数字
  isActive: z.boolean(),      // 布尔值
  tags: z.array(z.string())   // 数组
});

带约束的类型
const constrainedSchema = z.object({
  name: z.string().min(1).max(100),           // 长度限制
  age: z.number().min(0).max(150),            // 数值范围
  email: z.string().email(),                  // 邮箱格式
  url: z.string().url(),                      // URL格式
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) // 正则匹配
});

可选和默认值
const optionalSchema = z.object({
  required: z.string(),                        // 必填
  optional: z.string().optional(),             // 可选
  withDefault: z.string().default("默认值")     // 带默认值
});

枚举类型
const enumSchema = z.object({
  status: z.enum(["pending", "done", "cancelled"]),
  priority: z.enum(["low", "normal", "high"]).default("normal")
});

tool 错误处理

import { DynamicStructuredTool } from "langchain";
import z from "zod";

const robustTool = new DynamicStructuredTool({
  name: "read_file",
  description: "读取文件内容",
  schema: z.object({
    path: z.string().describe("文件路径")
  }),
  func: async ({ path }) => {
    try {
      const content = await fs.readFile(path, "utf-8");
      
      // 限制返回长度,避免Token超限
      if (content.length > 5000) {
        return `${content.slice(0, 5000)}\n...(文件内容过长,已截断)`;
      }
      
      return content;
    } catch (error) {
      // 返回结构化错误,让AI能理解
      if (error.code === "ENOENT") {
        return `错误:文件 "${path}" 不存在。请检查文件路径是否正确。`;
      }
      if (error.code === "EACCES") {
        return `错误:没有权限读取文件 "${path}"。`;
      }
      return `错误:读取文件失败 - ${error.message}`;
    }
  }
});

const res = await robustTool.func({ path: './package.json' });
console.log(res);

自定义工具类

import { Tool } from "@langchain/core/tools";

class CurrentTimeTool extends Tool {
  name = "get_current_time";
  description = "获取当前时间,输入时区(可选),返回当前日期和时间";
  
  async _call(input: string): Promise<string> {
    const timezone = input || "Asia/Shanghai";
    const now = new Date();
    return `当前时间 (${timezone}): ${now.toLocaleString('zh-CN', { timeZone: timezone })}`;
  }
}

const timeTool = new CurrentTimeTool();

const result = await timeTool.invoke("Asia/Shanghai");
console.log(result); // 输出: 当前时间 (Asia/Shanghai): 2026/3/31 7:19:16

记忆Memory

import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage } from "@langchain/core/messages";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from 'dotenv';
dotenv.config();

// 配置 DeepSeek 模型
const model = new ChatOpenAI({ 
  model: "deepseek-chat",
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: process.env.DEEPSEEK_API_BASE_URL,
  }
});

// 创建消息历史存储
const messageHistories = {};

// 创建带历史记录的链
const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个友好的AI助手。"],
  new MessagesPlaceholder("history"),
  ["human", "{input}"],
]);
const chain = prompt.pipe(model);

const chainWithHistory = new RunnableWithMessageHistory({
  runnable: chain,
  getMessageHistory: async (sessionId) => {
    if (!messageHistories[sessionId]) {
      messageHistories[sessionId] = new InMemoryChatMessageHistory();
    }
    return messageHistories[sessionId];
  },
  inputMessagesKey: "input",
  historyMessagesKey: "history",
});

// 多轮对话测试
async function runConversation() {
  const sessionId = "user-123";
  
  // 第一轮对话
  const result1 = await chainWithHistory.invoke(
    { input: "你好,我叫小明" },
    { configurable: { sessionId } }
  );
  console.log("AI:", result1.content);
  
  // 第二轮对话
  const result2 = await chainWithHistory.invoke(
    { input: "我是一名程序员" },
    { configurable: { sessionId } }
  );
  console.log("AI:", result2.content);
  
  // 第三轮对话 - AI 会记住前面的信息
  const result3 = await chainWithHistory.invoke(
    { input: "我叫什么名字?做什么工作?" },
    { configurable: { sessionId } }
  );
  console.log("AI:", result3.content);
  
  // 查看记忆内容
  const history = messageHistories[sessionId];
  const messages = await history?.getMessages();
  console.log("\n=== 记忆内容 ===");
  messages?.forEach((msg, idx) => {
    const role = msg instanceof HumanMessage ? "用户" : "AI";
    console.log(`${idx + 1}. ${role}: ${msg.content}`);
  });
}

runConversation();

Agents:智能体

import { AgentExecutor, createToolCallingAgent } from "@langchain/classic/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { DynamicTool } from "@langchain/core/tools";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from 'dotenv';
dotenv.config();

// 配置 DeepSeek 模型
const model = new ChatOpenAI({ 
  model: "deepseek-chat",
  temperature: 0.7,
  apiKey: process.env.DEEPSEEK_API_KEY,
  configuration: {
    baseURL: process.env.DEEPSEEK_API_BASE_URL,
  }
});

// 1. 定义工具
// 天气工具
const weatherTool = new DynamicTool({
  name: "get_weather",
  description: "获取指定城市的天气信息。输入城市名称,返回天气情况。",
  func: async (city) => {
    const weatherData = {
      "北京": "晴天,25°C,微风",
      "上海": "多云,28°C,湿度60%",
      "广州": "雷阵雨,30°C,注意带伞"
    };
    return weatherData[city] || `${city}的天气:晴转多云,温度适中`;
  }
});

// 计算器工具
const calculatorTool = new DynamicTool({
  name: "calculator",
  description: "计算数学表达式。输入数学表达式如 '23 * 45',返回计算结果。",
  func: async (expression) => {
    try {
      const result = eval(expression);
      return `${expression} = ${result}`;
    } catch (error) {
      return `计算错误:${error.message}`;
    }
  }
});

// 2. 定义提示模板
// 注意:不能自定义 system 提示,必须使用 MessagesPlaceholder("agent_scratchpad")
const prompt = ChatPromptTemplate.fromMessages([
  ["system", "You are a helpful assistant"], // 必须是英文基础提示(兼容工具调用)
  new MessagesPlaceholder("chat_history"),
  ["human", "{input}"],
  new MessagesPlaceholder("agent_scratchpad"), // 必须保留,不能修改
]);

// 3. 创建 Agent
const agent = await createToolCallingAgent({
  llm: model,
  tools: [weatherTool, calculatorTool], 
  prompt,
});

// 4. 创建 Agent 执行器
const executor = new AgentExecutor({
  agent,
  tools: [weatherTool, calculatorTool], 
  maxIterations: 5,
  verbose: true,
  returnIntermediateSteps: true,
});

// 5. 执行
const result = await executor.invoke({
  input: "北京天气怎么样?然后帮我算一下 23*45",
  chat_history: [] // 传入 chat_history,用于支持聊天历史
});

console.log("\n最终答案:", result);

Langchain实现一个简单的带记忆的对话

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import dotenv from "dotenv";

dotenv.config();

// 自定义 BufferMemory 类
class BufferMemory {
  private messages: BaseMessage[] = [];
  private maxMessages: number = 20;

  constructor(options?: { maxMessages?: number }) {
    this.maxMessages = options?.maxMessages || 20;
  }

  async addUserMessage(content: string) {
    this.messages.push(new HumanMessage(content));
    this.trimMessages();
  }

  async addAIMessage(content: string) {
    this.messages.push(new AIMessage(content));
    this.trimMessages();
  }

  private trimMessages() {
    if (this.messages.length > this.maxMessages) {
      this.messages = this.messages.slice(-this.maxMessages);
    }
  }

  async getHistory() {
    return this.messages;
  }

  async clear() {
    this.messages = [];
  }
}

async function chatWithMemory() {
  // 配置模型
  const model = new ChatOpenAI({
    model: "deepseek-chat",
    temperature: 0.7,
    apiKey: process.env.DEEPSEEK_API_KEY,
    configuration: {
      baseURL: process.env.DEEPSEEK_API_BASE_URL
    }
  });

  // 创建记忆实例
  const memory = new BufferMemory({ maxMessages: 10 });

  // 创建提示模板
  const prompt = ChatPromptTemplate.fromMessages([
    ["system", "你是一个友好的AI助手,用中文回答问题。"],
    new MessagesPlaceholder("history"),
    ["human", "{input}"]
  ]);

  // 创建对话函数
  async function chat(input: string): Promise<string> {
    // 获取历史消息
    const history = await memory.getHistory();
    
    // 构建输入
    const formattedPrompt = await prompt.formatMessages({
      input,
      history
    });
    
    // 调用模型
    const response = await model.invoke(formattedPrompt);
    const responseText = response.content as string;
    
    // 保存到记忆
    await memory.addUserMessage(input);
    await memory.addAIMessage(responseText);
    
    return responseText;
  }

  // 多轮对话
  const response1 = await chat("我叫张三");
  console.log("AI:", response1);

  const response2 = await chat("我叫什么名字?");
  console.log("AI:", response2); // 会记得名字

  // 查看历史
  const history = await memory.getHistory();
  console.log("\n=== 对话历史 ===");
  history.forEach((msg, idx) => {
    const role = msg instanceof HumanMessage ? "用户" : "AI";
    console.log(`${idx + 1}. ${role}: ${msg.content}`);
  });
}

chatWithMemory();

什么时候用 LangChain?

场景 推荐度 原因
多轮对话 + 记忆管理 ✅ 强烈推荐 Memory组件非常方便
多工具 Agent 系统 ✅ 强烈推荐 省去大量循环代码
RAG 应用(文档+检索) ✅ 强烈推荐 内置检索器、向量存储
生产级应用(需可观测性) ✅ 强烈推荐 LangSmith追踪、回调
快速原型开发 ✅ 强烈推荐 组件组合,快速迭代

Rsbuild 2.0 发布:即将支持 TanStack Start

作者 WebInfra
2026年4月23日 17:23

我们很高兴地宣布 Rsbuild 2.0 已经正式发布!

Rsbuild 是一个由 Rspack 驱动的现代 Web 应用构建工具,也是 Rstack 生态的重要基础设施。围绕 Rsbuild,我们陆续打造了一系列上层工具,包括 RspressRslibRstestStorybook Rsbuild 等。这些工具通过 Rsbuild 共享统一的构建能力与插件体系,在应用开发、库构建、文档站点以及测试等场景中提供一致的开发体验。

自 1.0 发布以来,Rsbuild 的 npm 周下载量已增长超过 15 倍,并成为 Rspack 新项目的首选构建工具。与此同时,越来越多团队从 webpack、Create React App 等工具迁移至 Rsbuild,并在构建效率和开发体验上获得了提升。

为了帮助生态平稳升级到 2.0,我们投入了三个月进行验证与打磨,期间发布了 20 多个预览版本。目前,Rslib、Rstest、Rspress、Storybook Rsbuild 和 Modern.js 均已完成升级,并在生产环境中稳定运行。

2.0 版本的主要改进包括:

  • 新特性:
    • 升级 Rspack 2.0
    • React Server Components 支持
    • 开发服务器与客户端通信
    • 支持扩展内置 Server
    • 支持自定义 logger
    • 更易用的拆包配置
    • create-rsbuild 模板更新
  • 更轻量:
    • 默认依赖从 13 个减少到 4 个
  • 更安全:
    • 默认仅监听 'localhost'
    • Proxy 中间件升级,支持 HTTP/2 代理
  • 更现代:
    • Pure ESM 包
    • 不再支持 Node.js 18
    • 默认目标环境更新
    • 默认输出 ESM Node.js 产物
    • 默认使用 '2023-11' 装饰器版本

升级 Rspack 2.0

Rsbuild 2.0 基于 Rspack 2.0 实现,因此也继承了 Rspack 2.0 在构建性能、产物优化和底层能力上的一系列改进。

参考 Rspack 2.0 博客 了解这部分变更。

React Server Components 支持

React Server Components (RSC) 是一种预先渲染的 React 组件类型,它将数据获取与组件逻辑结合起来,并减少发送到客户端的 JavaScript。

为了帮助基于 Rsbuild 的 Web 应用或框架更便捷地使用 RSC,我们提供了 rsbuild-plugin-rsc 插件。该插件基于 Rspack 内置的 RSC 能力实现,并借助 Rsbuild 的 Environments API 对 client 与 server 等多环境进行统一组织,降低了接入与配置成本。

import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginRSC } from 'rsbuild-plugin-rsc';

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginRSC({
      // Plugin options
    }),
  ],
  environments: {
    server: {
      // Server config...
    },
    client: {
      // Client config...
    },
  },
});

目前该插件仍处于实验阶段。它已经能够运行 React Router 的 RSC 示例,也已经在 Modern.js 框架 中落地使用。

另外,我们也在与 TanStack 团队展开合作,计划在后续版本中提供对 TanStack StartTanStack 的 RSC 的支持。TanStack Start 是一个基于 TanStack Router 构建的全栈框架,我们非常期待结合双方的能力,共同探索 RSC 在不同场景下的更多可能性。

开发服务器与客户端通信

在支持 React Server Components 的过程中,我们发现一些场景需要在开发服务器与浏览器之间进行通信。例如,服务端完成某些操作后,需要主动通知客户端执行对应逻辑。

为此,Rsbuild 2.0 提供了一组通信 API:

  • 服务端可通过 hot.send 向当前 environment 对应的客户端发送消息
  • 客户端可通过 import.meta.webpackHot.on 监听这些自定义事件

这些 API 复用了现有的 HMR 通道,无需额外创建 WebSocket 连接。同时,消息仅会发送到匹配的 environment,避免不必要的广播。

例如,当服务端状态发生变化时,通知客户端更新,而不是触发整页刷新:

  • 在服务端触发消息:
// rsbuild.config.ts
server.environments.web.hot.send('data-change', {
  count: 1,
});
  • 在客户端监听消息:
if (import.meta.webpackHot) {
  import.meta.webpackHot.on('data-change', ({ count }) => {
    console.log('data updated:', count);
  });
}

扩展内置 Server

Rsbuild 2.0 新增了 server.setup 选项,用于在开发服务器或预览服务器启动时执行初始化逻辑。

该选项相较于原有的 server.setupMiddlewares 更为强大,用于对 Rsbuild 内置服务器进行定制,例如注册中间件、执行启动前任务,或根据 dev / preview 模式注入不同逻辑。通过 server.setup,这些能力可以直接在 Rsbuild 配置中完成。

例如,为本地开发和预览环境添加一个简单的接口:

// rsbuild.config.ts
export default {
  server: {
    setup: ({ server }) => {
      server.middlewares.use((req, res, next) => {
        if (req.url === '/api/health') {
          res.end('ok');
          return;
        }
        next();
      });
    },
  },
};

支持自定义 logger

通过新增的 customLogger 选项,你可以为多个 Rsbuild 实例自定义不同的 logger。

这允许你为不同 Rsbuild 实例设置不同的日志级别、输出前缀,或者接入自定义的日志系统,而无需修改 全局 logger 实例

// rsbuild.config.ts
import { createLogger, defineConfig } from '@rsbuild/core';

const customLogger = createLogger({
  level: 'warn',
  prefix: '[web]',
});

export default defineConfig({
  customLogger,
});

查看 日志指南 了解更多。

更易用的拆包配置

在 1.x 中,Rsbuild 通过 performance.chunkSplit 封装了常见的拆包策略,但它的设计与 Rspack 的 splitChunks 差异较大,开发者需要额外理解 strategyforceSplitting 等概念。对于 coding agent 来说,也难以直接生成符合社区习惯的 splitChunks 配置,通常还需要进行额外转换。

因此,Rsbuild 2.0 提供了新的 splitChunks 选项。它的行为与 Rspack 的 splitChunks 完全对齐,并通过额外的 preset 选项来提供预设配置。

例如,使用 per-package 预设将每个 package 拆分成一个独立的 chunk:

// rsbuild.config.ts
export default {
  splitChunks: {
    preset: 'per-package',
    chunks: 'all',
  },
};

performance.chunkSplit 已在 2.0 中废弃,但现有配置仍可继续使用。建议参考 迁移 performance.chunkSplit 进行迁移。

create-rsbuild 模板更新

在核心能力升级的同时,我们也更新了 create-rsbuild 中的模板,使新项目的初始化流程更加贴近当前的开发实践:

  • 默认生成 AGENTS.md 文件,并支持在初始化时安装 rsbuild-best-practices 等 Agent Skills。
  • 创建 React 项目时,可以选择 React Compiler 作为可选工具。
  • 新增对 Rslint 的实验性支持,Rslint 是基于 typescript-go 的高性能代码检查工具。
  • 移除过时的 React 18 和 Vue 2 模板。

精简依赖

Rsbuild 2.0 对默认依赖进行了精简,将仅在特定场景下使用的包移出默认依赖,使默认依赖的数量从 13 个减少到 4 个,安装体积约减少 2 MB。

本次调整主要涉及:

默认 host 变化

server.host 的默认值从 '0.0.0.0' 调整为 'localhost'。开发和预览服务器默认仅监听本机,不再对局域网内的其他设备开放。

这一调整遵循「默认安全」的原则。在大多数本地开发场景中,开发服务器无需对外暴露。仅监听本机地址可以减少意外暴露,降低在共享网络环境中被扫描或攻击的风险。

如果你需要在局域网设备上访问页面,可以显式开启网络访问:

// rsbuild.config.ts
export default {
  server: {
    host: '0.0.0.0',
  },
};

也可以通过 CLI 的 --host 参数快速开启:

rsbuild --host

Proxy 中间件升级

开发服务器使用的 http-proxy-middleware 已经从 v2 升级至最新的 v4 版本,同时其底层依赖从已经停止维护的 http-proxy 切换为由 unjs 社区 积极维护的 httpxy

这主要带来几点改进:

  • 支持 HTTP/2 代理
  • 解决已知的安全问题
  • 不再依赖 Node.js 已废弃的 url.parse() API

server.proxy 的部分字段已发生变更,升级时请参考 从 v1 升级到 v2

Pure ESM 包

@rsbuild/core 现在以 pure ESM 包的形式发布,并移除了自身的 CommonJS 构建产物。这一调整仅影响 Rsbuild 本身的发布形式,使安装体积减少了约 500KB。

在 Node.js 20 及以上版本中,运行时已原生支持通过 require(esm) 加载 ESM 模块。因此,对大多数仍通过 JavaScript API 使用 Rsbuild 的项目来说,这一变更通常不会带来实际影响,也无需额外修改现有代码。

Node.js 支持

从 2.0 开始,Rsbuild 最低支持的 Node.js 版本为 20.19+22.12+。由于 Node.js 18 已于 2025 年 4 月底结束维护,2.0 也不再继续支持该版本。

我们通常会在某个 Node.js 版本进入 EOL 约一年后再移除支持,以为社区和用户预留更充足的升级时间。

默认目标环境更新

Rsbuild 2.0 调整了默认的目标环境,使产物面向更现代的浏览器和 Node.js 版本。

对于 Web 产物,默认的 browserslist 现在对齐到 Baseline 的广泛可用范围。这里选取的是 2025-05-01 对应的目标集,表示截至该时间点已被主流浏览器广泛支持的 Web 平台能力。

默认值变化如下:

  • Chrome 87 → 107
  • Edge 88 → 107
  • Firefox 78 → 104
  • Safari 14 → 16

这意味着在未显式配置 browserslist 的情况下,Rsbuild 会默认输出更现代的 JavaScript 和 CSS,同时减少语法降级和 polyfill 的引入。

对于 Node.js 产物,默认目标版本也从 Node.js 16 提升至 Node.js 20。

如果你已经通过 .browserslistrcpackage.json#browserslistoutput.overrideBrowserslist 显式配置了目标环境,则不会受到上述调整的影响。

ESM Node.js 产物

在构建 Node.js 产物时,相比 Rsbuild v1 默认输出压缩后的 CommonJS 代码,Rsbuild 2.0 现在会默认输出未压缩的 ES modules 代码。

这一调整更符合现代 Node 应用的主流实践。同时,服务端代码默认不压缩,有助于保留清晰的调试堆栈,提升问题排查效率。

需要注意的是,运行时需要具备加载 ESM 的能力。例如在 package.json 中设置 "type": "module",或者使用 .mjs 作为输出文件扩展名。如果你的项目仍依赖 CommonJS,可以显式切回原有行为:

// rsbuild.config.ts
export default {
  output: {
    target: 'node',
    module: false,
    minify: true,
  },
};

装饰器版本更新

随着底层 SWC 支持 2023-11 装饰器版本,Rsbuild 将 decorators.version 默认值从 2022-03 调整为 2023-11

2023-11 是当前最新的提案版本,对应 2023 年 11 月 TC39 会议后的规范,同时也是 Babel 8 的默认行为。如果你需要保留旧行为,可以显式指定版本:

// rsbuild.config.ts
export default {
  source: {
    decorators: {
      version: '2022-03',
    },
  },
};

升级至 Rsbuild 2.0

对于大多数项目来说,升级到 Rsbuild 2.0 是一个相对平滑的过程。尽管 2.0 引入了一些默认行为调整和不兼容变更,但大多数变更都提供了清晰的迁移路径,通常无需修改业务代码。

如果你正在使用支持 skills 的 coding agent,可以安装 rsbuild-v2-upgrade skill,由 agent 自动协助完成依赖升级、配置调整和迁移检查,减少手动操作成本。

npx skills add rstackjs/agent-skills --skill rsbuild-v2-upgrade

完整的升级指南及所有不兼容变更,请参考 从 v1 升级到 v2

致谢

Rsbuild 由 Rstack 团队主导开发,同时也离不开社区贡献者与所有用户的共同参与。自 1.0 发布以来,许多开发者通过贡献推动了 Rsbuild 的演进,在此感谢所有参与其中的朋友:

@9aoy@adammark@ahabhgk@alexUXUI@bodia-uz@Brennvo@caohuilin@Cheese-Yu@chenjiahan@Chevindu@Colin3191@colinaaa@CPunisher@davide97g@Deku-nattsu@DeveshSapkale@dovigod@Draculabo@easy1090@escaton@fansenze@fi3ework@gaoachao@GiveMe-A-Name@GRAMMAC1@hai-x@hangCode2001@hardfist@hasnum-stack@htoooth@Huxpro@ianzone@iceprosurface@inottn@jerrykingxyz@jkzing@JounQin@JSerFeng@JSH-data@junhea@junxiongchu@lguzzon@LingyuCoder@lluisemper@lxKylin@mhutter@miownag@mycoin@nikhilsnayak@notzheng@Nsttt@nyqykk@puxiao@qmakani@quininer@RobHannay@roli-lpci@s-chance@s-r-x@sagar-dwivedi@Sang-Sang33@schu34@ScriptedAlchemy@Shucei@shulaoda@Simon-He95@slobo@snatvb@SoonIter@stormslowly@SyMind@T9-Forever@thinkasany@Timeless0911@TinsFox@valorkin@vegerot@VenDream@wangi4myself@wChenonly@wjw99830@wralith@wxiaoyun@xbzhang2020@xc2@xettri@xiaohp@xuexb@xun082@yifancong@ymq001@zackarychapple@zalishchuk@zoolsher

前端性能指标速查手册

作者 antkang
2026年4月23日 17:19

前端性能指标速查手册

一、一张时间线先理清所有指标的位置

用户点链接
    │
    ▼                                                   用户能交互
    ─────────────────────────────────────────────────────┐
    0              服务器返回  DOM解析完  最大内容出现     所有资源下完
    │                 │          │           │            │
    │ ←── TTFB ──→    │          │           │            │
    │                 │ ←─ DCL ─→│           │            │
    │        ←──── FP / FCP ────→│           │            │
    │        ←──────── LCP ─────────────────→│            │
    │        ←───────────────── Load ──────────────────→  │
    │        ←───────────────── TTI ──────────────────→   │
    ▼
导航开始(performance.timeOrigin 零点)

理解所有指标的第一件事:它们都用同一把尺子量(距离导航开始多少 ms),所以能直接比较、相减。


二、核心指标,按"谁先谁后"排列

1. TTFB · Time To First Byte

"服务器返回 HTML 第一个字节要多久"

  • 回答:网络 + 服务端 有没有问题
  • 包括:DNS 解析、TCP 握手、TLS 协商、服务端处理、HTML 开始返回
  • 看它发现什么:后端慢、CDN 配错、DNS 查询慢、HTTPS 握手慢

测量方式

const nav = performance.getEntriesByType('navigation')[0];
const ttfb = nav.responseStart; // 从导航开始到收到第一个字节

达标线:< 200ms 好 / 200~600ms 可接受 / > 600ms 要改


2. FP · First Paint

"浏览器第一次往屏幕刷像素"(哪怕只是背景色)

  • 实用意义不大,因为画一个灰色背景也算 FP
  • 通常你会忽略它,直接看 FCP

3. FCP · First Contentful Paint(关键

"浏览器第一次画出有意义的内容"(文本、图片、SVG、canvas 等)

  • 用户视角:"白屏结束了"
  • 被 Google 官方定义为 Core Web Vital 的"加载"维度之一
  • 注意:SPA 里 FCP 可能是 index.html 的 loading 骨架,而不是应用真正渲染

测量方式

new PerformanceObserver((list) => {
  list.getEntries().forEach((e) => {
    if (e.name === 'first-contentful-paint') {
      console.log('FCP:', e.startTime);
    }
  });
}).observe({ type: 'paint', buffered: true });

达标线:< 1.8s 好 / 1.8~3s 可接受 / > 3s 差


4. DCL · DOMContentLoaded

"HTML 全部解析完 + 所有 defer / module script 执行完"

  • 不等图片、样式、iframe
  • 对 SPA 意义比传统页面小(SPA 的 DOM 大部分是 JS 构造的,不在 HTML 里)

测量方式

nav.domContentLoadedEventEnd;

5. LCP · Largest Contentful Paint(Core Web Vital

"页面主要视觉区域画完的时刻"(页面里最大的那个内容块)

  • 回答:用户觉得页面"差不多好了" 是什么时候
  • Google 三大 Core Web Vitals 之一,直接影响 SEO
  • 会随内容变大而不断更新,直到用户首次交互才定格
  • 比 FCP 更贴近真实用户体感

测量方式

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lcp = entries[entries.length - 1]; // 最后一次上报的才是最终 LCP
  console.log('LCP:', lcp.startTime, 'element:', lcp.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });

达标线< 2.5s 好 / 2.5~4s 可接受 / > 4s 差(Google 硬标准)


6. Load

"HTML 声明的所有资源都下完"(含图、CSS、子 iframe)

  • 不等:动态 import、运行时 fetch、new Image
  • 在 SPA 里意义很小,常早于应用真正可用
  • 看它的主要用途:检查初始 bundle + 关键资源加载是否异常
nav.loadEventEnd;

7. TTI · Time To Interactive

"主线程连续空闲 5 秒,可稳定响应交互"

  • 传统但难精确测量(需要看 long task、FCP、资源加载多重信号)
  • 浏览器不直接暴露 —— 需要算法推断
  • 建议用 web-vitals 库或 Lighthouse 测,不要手写

8. INP · Interaction to Next Paint(Core Web Vital,2024 替换 FID

"用户每次交互(点击/输入)到下一次画面更新的耗时"

  • 可交互性(FCP/LCP 测"能看",INP 测"能用")
  • 贯穿整个页面生命周期,不是首屏指标
  • 反映 JS 主线程是否卡

测量方式:强烈建议用 web-vitals 库,手写很复杂

达标线:< 200ms 好 / 200~500ms 可接受 / > 500ms 差


9. CLS · Cumulative Layout Shift(Core Web Vital

"页面上所有意外的布局偏移累积分数"

  • 图片没写 width/height、字体替换、广告插入都会引发 CLS
  • 不是时间指标,是一个 相对分数
  • 反映视觉稳定性

达标线:< 0.1 好 / 0.1~0.25 可接受 / > 0.25 差


10. TBT · Total Blocking Time

"FCP 到 TTI 之间,主线程被长任务阻塞的总时长"

  • 长任务 = 单次执行 > 50ms 的 JS 任务
  • 反映加载阶段的 JS 臃肿程度
  • Lighthouse 核心指标,但不是 Core Web Vital

三、如何测 · 四种通用方案

方案 适用 优点 缺点
Chrome DevTools Performance 面板 一次性调试 零代码、直观、火焰图详细 手动录制,没法长期监控
Lighthouse 一次性评分 综合报告,含建议 实验室环境,不代表真实用户
自己 PerformanceObserver 长期埋点 灵活、零依赖 要写代码,边界 case 要自己处理
web-vitals 库(Google 官方) 线上监控 规范、跨浏览器、支持 INP/CLS +3KB(但值)

推荐的最小 web-vitals 接入

import { onCLS, onFCP, onLCP, onINP, onTTFB } from 'web-vitals';

const send = (metric) => {
  // 上报到你的监控后端
  navigator.sendBeacon('/api/metrics', JSON.stringify(metric));
};

onCLS(send);
onFCP(send);
onLCP(send);
onINP(send);
onTTFB(send);

三行代码覆盖所有 Core Web Vitals,Google 官方维护,永远比自写的准。


四、自定义测量:performance.mark / performance.measure

浏览器自动提供的指标只是"公共节点"。你自己关心的节点要自己打标

// 标一个时间点
performance.mark('tab-switched');

// 标一段耗时
performance.mark('data-fetch-start');
await fetchData();
performance.mark('data-fetch-end');
performance.measure('data-fetch-duration', 'data-fetch-start', 'data-fetch-end');

// 读出来
const m = performance.getEntriesByName('data-fetch-duration')[0];
console.log(m.duration);

SPA 里最常见的自定义节点

  • app-script-start:JS 开始执行
  • app-mounted:框架首屏挂载完
  • route-change-start / route-change-end:路由跳转
  • api-call-start / api-call-end:接口调用

五、指标挑选原则(SPA 视角)

你想知道 看这个
服务端/网络慢不慢 TTFB
用户白屏多久 FCP
用户真觉得"能看了"多久 LCP
首屏真正可交互多久 自定义 app-mounted mark
每次点击是否卡顿 INP
页面是否在跳动 CLS
路由跳转多久 自定义 route-change measure
接口慢不慢 Resource Timing APIgetEntriesByType('resource')

六、一句话记住每个指标的"灵魂"

  • TTFB:网络+后端的成绩单
  • FCP:白屏结束的那一刻
  • LCP:用户觉得"页面好了"的那一刻(Google 认可的"加载"
  • DCL:HTML 骨架完成的那一刻
  • Load:HTML 声明的资源全下完(SPA 里不重要)
  • TTI:主线程稳定空闲(理论可交互)
  • INP:每次交互的响应速度(Google 认可的"交互"
  • CLS:页面跳不跳(Google 认可的"稳定"
  • TBT:加载阶段 JS 卡不卡

Core Web Vitals 三件套 = LCP + INP + CLS。这三个是 Google 搜索排名会用的,也是线上监控的最小必要集。其他都是辅助。


七、一条黄金路径建议

不上线:用 Chrome DevTools Performance + Lighthouse 足够,不用写埋点。

上线后:接 web-vitals + 自建或对接 APM(Sentry / 阿里 ARMS / 自建后端)。监控 Core Web Vitals + 若干自定义 mark 就够。

不要做的事

  • 手写 TTI / CLS / INP 计算(必错)
  • 在 dev 模式看性能下结论(几乎永远比 prod 慢 2~5 倍)
  • 只看平均值(看 p75 或 p95 才贴近真实用户体验)

OpenMUSE 全面详解:非扩散Transformer文生图开源基座(对标GPT Image 2)

2026年4月23日 17:11

大家好,我是安东尼(tuaran.me),一名专注于前端与 AI 工程化的独立开发者。

我在建设 「博主联盟」——连接AI产品方与技术博主的品牌增长平台,帮AI产品精准触达开发者,也帮博主拿到推广资源与成长机会。

同时也在做 「前端下一步」——一个聚焦前端、AI Agent 与大模型的技术情报站,帮你从技术革新焦虑中解脱,得到技术转向判断。

这篇文章,希望对你有所启发。

26d4ed63-b351-4c43-a7e8-aa8fd7e0f7a4.png

一、前言

当前主流文生图模型(Stable Diffusion、DALL·E系列)均基于Diffusion扩散架构,普遍存在文字渲染崩坏、构图逻辑差、推理步骤多、上下文语义丢失等痛点。而OpenAI最新闭源生图模型GPT Image 2彻底抛弃扩散路线,采用Transformer自回归Token生成范式,在密集文字、复杂构图、现实世界还原上实现断层领先,但全程闭源无法本地部署与二次改造。

Hugging Face开源的OpenMUSE,是目前开源社区最贴近GPT Image 2技术路线的原生Transformer文生图基座,基于Google原始MUSE掩码生成范式重构,全代码、权重开源,支持本地私有化部署、企业二次微调,是自研数字员工智绘模块、通用AI绘图能力建设的优选底层底座。

二、OpenMUSE 基础简介

2.1 模型溯源

OpenMUSE 为 Hugging Face 官方开源复现项目,完整复刻 Google MUSE 论文 MaskGit 掩码Transformer文生图方案。

  • 项目仓库:github.com/huggingface…
  • 开源协议:Apache 2.0,允许本地部署、商用、闭源二次改造、领域微调,无版权风险
  • 训练数据集:基于 LAION-2B、COYO-700M 大规模图文数据预训练
  • 社区轻量衍生版:aMUSEd,大幅降参降显存门槛,工业落地首选

2.2 核心定位

非扩散、纯Transformer序列生成文生图模型,完全摒弃Diffusion去噪管线,以离散视觉Token为媒介完成图像生成,天生解决扩散模型文字差、构图乱、语义脱节的原生缺陷,是对标闭源GPT Image 2架构路线的最优开源备选。

三、模型架构与生成原理

OpenMUSE 整体流水线无Unet、无多步扩散去噪,全程分为三大模块,链路简洁可控:

文本Prompt → CLIP文本编码器 → MaskGit Transformer主干 → VQGAN编解码 → 输出图像

3.1 模块拆解

  1. 文本编码层
    采用CLIP-L/14文本编码器,完成自然语言提示词语义向量化,完成基础图文对齐。
  2. 主干网络:MaskGit Transformer
    模型核心模块,掩码Token预测机制:先初始化掩码图像Token序列,多轮迭代逐步还原有效视觉Token,属于离散序列生成范式。
    对比扩散模型多步噪声迭代,OpenMUSE推理步数更少、画面布局一致性更强、空间结构逻辑更严谨。
  3. VQGAN 视觉编解码
    实现离散图像Token与像素图像的双向转换,将Transformer生成的Token序列还原为可视化图片,同时支持图像压缩与分辨率适配。

3.2 核心生成差异(vs 扩散模型SD/DALL·E)

对比维度 OpenMUSE(MaskGit Transformer) Stable Diffusion 扩散模型
底层架构 纯Transformer掩码序列生成 隐空间扩散+多步去噪迭代
推理步数 少步快速生成,无冗余迭代 20~50步采样,推理速度慢
文字渲染能力 原生Token级排版,文字不易崩坏 像素拟合,密集文字极易模糊错乱
构图可控性 全局布局规划,实体一致性高 局部像素生成,空间逻辑易混乱
可解释性 高,Token生成过程可追溯 低,去噪黑盒难以溯源
微调成本 轻量化易微调,小样本适配快 训练成本高,领域适配繁琐

四、参数量与硬件部署要求

4.1 官方权重参数量

  • OpenMUSE Base(256×256):1.2B 参数
  • OpenMUSE Large(512×512):1.5B 参数
  • 社区轻量版 aMUSEd:800M 参数,消费级显卡友好

4.2 本地部署硬件门槛(实测)

原版 OpenMUSE

  • 最低显卡:RTX 3090 / A10 24G 显存
  • 推荐显卡:RTX 4090、A100 40G
  • 显存占用:18~22GB
  • 推理速度:512×512 图像 8~15s/张

轻量版 aMUSEd(工业落地首选)

  • 最低显卡:RTX 3060 12G 即可本地离线运行
  • 显存占用:8~11GB,支持4/8bit量化压缩
  • 推理速度:512×512 图像 4~7s/张
  • 部署环境:Python 3.9+、PyTorch 1.13.1、CUDA 11.7,支持Linux、Windows、Docker容器化部署

五、OpenMUSE 优缺点全解析

5.1 优势亮点

  1. 架构路线对标GPT Image 2
    同属非扩散Transformer生成范式,从根源解决扩散模型文字崩坏、构图混乱痛点,契合自研智绘官通用出图、海报UI、图文排版场景需求。
  2. 全开源私有化可控
    代码、预训练权重、训练脚本完整开源,数据不出内网,支持深度二次改造、模块插拔、中文增强训练。
  3. 生成可控性强
    掩码序列生成机制带来稳定的画面布局、实体比例、空间结构,适合标准化业务素材生成。
  4. 轻量化易微调
    1.5B以内小参数量,普通算力集群即可完成领域微调、中文数据集增强、业务风格定制。
  5. 社区生态完善
    拥有量化方案、中文微调分支、VQGAN替换优化、推理加速工具,工业改造资料齐全。

5.2 现存短板

  1. 无MoE稀疏架构:稠密Transformer主干,无多专家任务分流,复杂多任务上限低于GPT Image 2。
  2. 无原生多模态思维链:仅文生图能力,缺少前置构图推理、联网校验、多图连贯生成模块。
  3. 原生中文能力薄弱:预训练以英文图文数据为主,密集中文、小字排版仍需额外微调优化。
  4. 分辨率上限较低:原生最高仅支持512×512,无原生4K超清输出能力。
  5. 现实常识知识匮乏:无真实商品、品牌、物理世界知识绑定,写实物体还原精度有限。

六、快速本地部署命令

# 1. 克隆官方开源仓库
git clone https://github.com/huggingface/open-muse.git
cd open-muse

# 2. 安装依赖环境
pip install -e ".[extra]"

# 3. 自动下载Hugging Face预训练权重,本地Pipeline推理
# 无需云端API,完全离线本地运行

七、自研落地应用总结(结合数字员工智绘模块)

GPT Image 2 全程闭源、仅API调用、无法私有化部署,OpenMUSE 是当前开源领域最优对标基座
结合企业数字员工应用中心建设,自研改造路线清晰:

  1. 选用aMUSEd轻量版完成本地私有化底座部署;
  2. 接入中文编码器与文字排版增强模块,补齐原生中文渲染短板;
  3. 外挂开源视觉思维链模块,增加前置构图规划能力,对标GPT Image 2思考生成机制;
  4. 基于内部业务素材做领域微调,适配通识海报、UI素材、常规图文出图需求。

八、总结

OpenMUSE 打破了扩散模型垄断,以Transformer掩码生成开辟开源文生图新路线,凭借全开源、本地可部署、可控可微调、构图文字原生优势,成为企业自研AI绘图、数字员工智绘能力建设的优质底层基座。虽在大模型融合、超高分辨率、深层世界知识上仍有短板,但通过模块外挂、领域微调即可补齐业务缺口,完美适配中小团队低成本自研对标闭源顶尖生图模型的技术需求。

一文搞定微信小程序双登录模式:授权登录 vs 手机号登录流程详解

2026年4月23日 17:10

在开发微信小程序时,很多新手会被 code、openid、access_token 等名词绕晕。 本文将带你通过“前后端联动”的方式,彻底搞定微信授权登录和手机号快捷登录。不仅有代码,还有Access_token 缓存优化的最佳实践。

1. 区别和流程对比

功能 流程步骤 核心标识
微信登录 1. 前端 uni.login()code2. 后端 jscode2sessionopenid + session_key3. 用 openid 查库/注册用户 4. 生成系统 token openid
手机号登录 1. 前端 wx.getPhoneNumber()phoneCode2. 后端先用 loginCodeopenid + session_key3. 用 phoneCode + session_key解密获取手机号 4. 用 openid 查库/注册用户 5. 生成系统 token phoneNumberopenid

解释一下其中有几个名词的意思:

1.1. loginCode

来源: 前端通过 uni.login() 拿到的 code。

作用: 这是一个临时凭证,有效期很短(5 分钟)。

用途: 必须发送给后端,后端再去请求微信接口(jscode2session),才能换取真正的用户标识(openidsession_key)。

类比:好比拿到了一张 “临时兑换券”,要拿去微信那里兑换成真实身份。

1.2. openid

来源: 后端用 loginCode 请求微信 jscode2session 接口返回的。

作用: 这是用户用户在小程序里面的唯一标识(对同一个小程序,openid 永远唯一)。

用途: 可以拿它来判断用户是不是注册过,类似数据库里面的 userId

类比:相当于微信告诉你「这个用户在你这里的身份证号」。

1.3. session_key

来源: 同样是 jscode2session 接口返回的。

作用: 是一个 会话密钥,用来解密用户敏感数据,比如手机号。

用途: session_key不能直接暴露给前端, 后端用 session_key解密能得到明文手机号。

类比:一把解锁手机信息的钥匙。

1.4. phoneCode

来源: 前端按钮 <button open-type="getPhoneNumber"> 获取的 code。

作用:这个 phoneCode 也是一个临时凭证。

用途:需要传递给后端,后端调用微信的 手机号获取接口 解密才能拿到真实的号码
类比:一张「手机号兑换券」,需要后端再去微信换。

接下来看看前后端如何实现的,这里我就直接在 Controller 里面写了,大家开发的时候一定要分层!

2. 实现小程序授权登录

2.1. 总流程

2.2. 前端如何实现

<template>
  <button @click="wxLogin">微信授权登录</button>
</template>

<script setup>
  const wxLogin = () => {
    // 第一步:调用 uni.getUserProfile() 获取用户信息
    uni.getUserProfile({
      desc: '用于完善会员资料', /
      success: (res) => {
        console.log('用户信息:', res.userInfo)
        // userInfo.value = res.userInfo // 这里可以存储一下
        // userInfo.value 包含:
        // nickName、avatarUrl、gender、province、city、country
      },
      fail: (err) => {
        console.log('用户拒绝授权', err)
      }
    })
    // 第二步:调用
    uni.login({
      success(res) {
        const loginCode = res.code
        // 第三步:请求后端接口,把 loginCode 传给后端,让后端处理
        const url = 'http://localhost:8080/wxUser/wxLogin';

        uni.request({
          url,
          method: 'POST',
          data: {
            loginCode,
          },
          success: (res) => {
            console.log(res)
            
           
          },
          fail: (err) => {
            console.log(err)
          }
        })
      }
    })
  }
</script>

注意:调用 uni.getUserProfile 我的基础库是选择的 2.16.1 版本,目前新版本无法触发官方的弹窗,并且拿到信息也是匿名的。

总结一下前端需要做的事是:uni.getUserProfile()(获取用户信息) 调用 uni.login()(获取 loginCode),最重要的就是这个 loginCode 需要把这个传递给后端。

2.3. 后端如何实现

先在 微信公众平台 拿到 appIdsecret (进入到管理,点击开发管理,点击开发设置)

@RestController
@RequestMapping("/wxUser")
public class WxUserController {

    @PostMapping("/wxLogin")
    public Result wxUser(@RequestBody WxUser wxUser){
        System.out.println(wxUser);

        // 第一步:拿到前端传递的 loginCode
        String loginCode = wxUser.getLoginCode();  // 这里是 wx.login 返回的 code

        // 第二步:拿到自己的 appId + secret
        String appId = "wxd00000000000";
        String secret = "00000000000000000000";
        RestTemplate restTemplate = new RestTemplate();

        // 第三步:用 loginCode + appId + secret 换 session_key + openid
        String sessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId
        + "&secret=" + secret
        + "&js_code=" + loginCode
        + "&grant_type=authorization_code";
        String sessionResponse = restTemplate.getForObject(sessionUrl, String.class);
        JSONObject sessionJson = JSONObject.parseObject(sessionResponse);
        String openid = sessionJson.getString("openid");

        System.out.println("openid:" + openid);

        // 剩下逻辑就拿着 openid 去库里查找有没有这个用户,如果没有就入库然后JWT生成token返回给前端,如果有的话就直接生成Token给前端即可

        return Result.success("登录成功");
    }
}

总结一下后端需要做的事是:拿到后端传过来的 loginCode 然后拿 appId + secret + loginCode 请求微信官方 api 去换 session_keyopenid,然后拿这个 openid来判断用户是否第一次登录,如果是第一次就入库注册然后生成 Token,如果不是第一次就生成 Token,然后 Token 以及其他需要的返回给前端。

3. 实现小程序手机号登录

3.1. 总流程

3.2. 前端如何实现

注意:要实现手机号授权,涉及到用户隐私,必须拥有调用该接口的权限,否则会报错。

微信小程序的 getPhoneNumber 接口仅对“非个人主体”且“已完成微信认证”的小程序开放,个人开发者账号无法使用此功能。

解决方案(二选一):

方案一:认证为企业小程序(推荐)

  • 登录 微信公众平台
  • 进入【设置】>【基本设置】>【微信认证】,按提示完成企业认证(需支付,1-3个工作日审核)
  • 认证成功后,重新启动微信开发者工具即可正常使用 getPhoneNumber 接口

方案二:使用微信测试号进行开发调试

  • 微信提供了测试号,允许开发者在未认证的情况下体验接口
<template>
  <!-- 关键在于 open-type="getPhoneNumber" 和 @getphonenumber 事件 -->
  <button open-type="getPhoneNumber" @getphonenumber="handleGetPhoneNumber">
    手机号一键登录
  </button>
</template>

<script setup>
  const handleGetPhoneNumber = async (e) => {
    console.log('手机号授权回调', e.detail);
    
    if (!e.detail.code) {
      uni.showToast({
        title: '您取消了授权',
        icon: 'none'
      });
      return;
    }

    // 1. 获取手机号登录凭证
    const phoneCode = e.detail.code;

    // 2. 获取登录凭证 code
    const loginRes = await uni.login();
    const loginCode = loginRes.code;

    // 3. 请求后端接口把 loginCode 和 phoneCode 传给后后端
    // 为什么传 loginCode:后端换取 opendi 生成JWT
    // 为什么传 phoneCode:后端通过这个 code 解码来拿到真实手机号返回给前端
    const url = 'http://localhost:8080/wxUser/wxPhoneLogin';

    uni.request({
      url,
      method: 'POST',
      data: { loginCode, phoneCode },
      success: (res) => {
        console.log(res)
      },
      fail: (err) => {
        console.log(err)
      }
    })

  }
</script>

<style>

</style>

总结一下前端需要做什么事:通过 open-type="getPhoneNumber" 和 @getphonenumber 事件拿到 phoneCode,通过 uni.login() 拿到 loginCode,把 phoneCodeloginCode 传给后端。

3.3. 后端如何实现

3.3.1. 创建一个专门管理 access_token 的服务

1. 为什么这么做:

防止每次调用手机号登录接口,都会去请求微信服务器换取一次 access_token。微信对 access_token 的获取有严格的调用频率限制。当用户量稍微大一点,你的服务器会因为频繁请求而被微信暂时封禁,导致所有手机号登录功能全部瘫痪。

2. 原理:

access_token 的有效期是 2 小时,并且在有效期内是全局唯一的。我们应该在它快过期前才去重新获取。

3. 如何做

  • 引入全局缓存:必须将 access_token 存放在一个全局的、可持久化的缓存中(比如 Redis,或者简单的内存缓存也可以)。
  • 收到请求后,先去缓存(如 Redis)里尝试获取 access_token,如果能获取到,并且没过期,直接使用,如果获取不到,或者已过期,再去调用微信接口获取一个新的 access_token,并立刻存入缓存,同时设置好过期时间(比如 7000 秒)
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class WxAccessTokenService {

    // 使用 @Value 注解从配置文件 application.properties 中读取配置
    @Value("${wx.appId}")
    private String appId;

    @Value("${wx.secret}")
    private String secret;

    // --- 内存缓存的核心 ---
    private String accessToken;
    private long expiresAt; // access_token 的过期时间点(毫秒)
    // --------------------

    /**
     * 获取 access_token 的主方法
     * 增加了 synchronized 关键字,确保在多线程环境下只有一个线程能获取新 token,防止并发问题。
     * @return 有效的 access_token
     */
    public synchronized String getAccessToken() {
        // 检查当前 token 是否存在且未过期
        if (this.accessToken != null && System.currentTimeMillis() < this.expiresAt) {
            System.out.println("从内存缓存中获取 accessToken");
            return this.accessToken;
        }

        // 如果 token 不存在或已过期,则重新获取
        System.out.println("缓存中 accessToken 已过期或不存在,重新获取...");
        fetchNewAccessToken();
        return this.accessToken;
    }

    /**
     * 从微信服务器获取新的 access_token 并更新缓存
     */
    private void fetchNewAccessToken() {
        RestTemplate restTemplate = new RestTemplate();
        String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential"
                + "&appid=" + this.appId
                + "&secret=" + this.secret;

        String tokenJsonResponse = restTemplate.getForObject(url, String.class);
        JSONObject tokenJson = JSONObject.parseObject(tokenJsonResponse);

        if (tokenJson != null && tokenJson.getString("access_token") != null) {
            this.accessToken = tokenJson.getString("access_token");
            long expiresIn = tokenJson.getLongValue("expires_in"); // 获取到的有效期,单位秒

            // 计算下一次的过期时间点。
            // 微信返回的 expires_in 是 7200 秒(2小时)。
            // 我们提前 10 分钟(600秒)让它过期,以防止因网络延迟等问题导致 token 恰好在临界点失效。
            this.expiresAt = System.currentTimeMillis() + (expiresIn - 600) * 1000;
            
            System.out.println("成功获取新的 accessToken: " + this.accessToken);
            System.out.println("accessToken 将在 " + new java.util.Date(this.expiresAt) + " 过期");
        } else {
            // 处理获取失败的情况
            System.err.println("获取 accessToken 失败: " + tokenJsonResponse);
            // 在实际项目中,这里应该抛出异常或进行更详细的错误处理
        }
    }
}

3.3.2. 创建 Controller

@RestController
@RequestMapping("/wxUser")
public class WxUserController {

    // 注入新创建的 access_token 管理服务
    @Autowired
    private WxAccessTokenService wxAccessTokenService;

    // 同样从配置文件读取,保持一致性
    @Value("${wx.appId}")
    private String appId;
    @Value("${wx.secret}")
    private String secret;

    @PostMapping("/wxPhoneLogin")
    public Result phoneWxUser(@RequestBody WxUser wxUser){
        System.out.println(wxUser);

        String phoneCode = wxUser.getPhoneCode();
        String loginCode = wxUser.getLoginCode();
        
        RestTemplate restTemplate = new RestTemplate();

        // 第一步:用 loginCode 换 openid
        String sessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId
                + "&secret=" + secret
                + "&js_code=" + loginCode
                + "&grant_type=authorization_code";
        String sessionResponse = restTemplate.getForObject(sessionUrl, String.class);
        JSONObject sessionJson = JSONObject.parseObject(sessionResponse);
        String openid = sessionJson.getString("openid");
        System.out.println("openid:" + openid);

        // 第二步:从我们的服务中获取 access_token
        String accessToken = wxAccessTokenService.getAccessToken();
        System.out.println("获取到的 accessToken:" + accessToken);

        // 如果 accessToken 获取失败,直接返回错误
        if (accessToken == null) {
            return Result.error("获取 accessToken 失败,请稍后重试");
        }
        
        // 第三步:用 accessToken + phoneCode 调用官方 api 换真实手机号 (这部分逻辑不变)
        String phoneUrl = UriComponentsBuilder
                .fromHttpUrl("https://api.weixin.qq.com/wxa/business/getuserphonenumber")
                .queryParam("access_token", accessToken)
                .toUriString();

        Map<String, String> params = new HashMap<>();
        params.put("code", phoneCode);

        String phoneResponse = restTemplate.postForObject(phoneUrl, params, String.class);
        System.out.println(phoneResponse);
        
        JSONObject phoneJson = JSONObject.parseObject(phoneResponse);
        if (phoneJson != null && phoneJson.getInteger("errcode") == 0) {
            JSONObject phoneInfo = phoneJson.getJSONObject("phone_info");
            String phoneNumber = phoneInfo.getString("phoneNumber");
            System.out.println("用户手机号:" + phoneNumber);
            // 这里可以继续业务逻辑,比如用 openid 或 phoneNumber 查找用户、注册、生成JWT等
        } else {
            String errmsg = phoneJson != null ? phoneJson.getString("errmsg") : "未知错误";
            System.out.println("获取手机号失败:" + errmsg);
            return Result.error("获取手机号失败: " + errmsg);
        }

        return Result.success("登录成功");
    }
}

3.3.3. 添加配置

为了让 @Value 注解生效,你需要在 src/main/resources/application.properties 文件中添加你的小程序配置:

wx.appId=wxd000000000
wx.secret=111111111111111111

3.3.4. 导入依赖

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>2.0.42</version> <!-- 使用最新稳定版本 -->
</dependency>

总结一下后端应该做什么:首先拿到前端传过来的phoneCodeloginCode ,用 loginCode + appId + secretsession_key + openid ,然后用 openidaccess_token(缓存) ,然后用 accessToken + phoneCode 调用官方 api 换真实手机号,然后拿 openid来判断用户是否第一次登录,如果是第一次就入库注册然后生成 Token,如果不是第一次就生成 Token,然后 Token 以及其他需要的返回给前端。

🚀 告别"一次性聊天":揭秘让 AI 智能体越用越聪明的秘密武器 —— Skills

2026年4月23日 17:10

🚀 告别"一次性聊天":揭秘让 AI 智能体越用越聪明的秘密武器 —— Skills

💡 导读:每次跟 AI 智能体(Agent)对话都要从头交代?上次纠正的错下次又犯?
其实它缺的不是智商,而是经验。一文讲透 Skills(技能) 机制,从概念到实战,让你的智能体拥有"程序性记忆",越用越聪明。

🌟 前言:聪明的"实习生"与贴墙的"操作手册"

想象一下,公司新来了一位实习生。 他聪明伶俐,反应极快。第一天,你让他给客户开发票。他没做过,问同事结果填错了一个字段。你纠正道:

"记住,客户编号要填在第二栏,不是第一栏。"

第二天,他又填错了。第三天,他又问了一遍。 这时候你会怎么办?

你大概率会写一份**《发票操作手册》**贴在墙上:

✅ 操作手册
1. 填第一栏...
2. 编号填第二栏...
3. ⚠️ 注意:千万别填反!

以后,不管是谁(甚至是一个新来的机器人),只要照着手册做,就不会再错。

🎯 核心洞察:AI 智能体就是那个"聪明的实习生"。Skills 就是给智能体编写的"操作手册",让它不再重复犯错,不再重复造轮子。

📖 一、什么是 Skills?

🧠 正式定义

Skills 是智能体的"程序性记忆"(Procedural Memory)。 它把曾经解决过的问题、踩过坑的经验、验证过的方法,沉淀成一份文档。下次遇到类似任务,智能体会自动加载并照着做。

🔄 核心概念辨析:智能体的"大脑"是怎么分工的?

概念 通俗理解 生命周期 举例
Session
(会话)
📝 便利贴 聊完即销毁 "刚才我们讨论了 A 项目进度"
Memory
(记忆)
📂 档案袋 跨会话持久 "用户喜欢中文"、"DB 地址 10.0.1.5"
Skills
(技能)
📖 操作手册 跨会话持久,可迭代 "遇到 B 问题:先 X 再 Y,⚠️ 别做 Z"

一句话总结

  • Memory 回答 "你是谁 / 环境怎样"(Fact)
  • Skills 回答 "这件事该怎么做"(How-to)

🍳 二、智能体的"全家桶":Skills 与 Prompt、Memory、Tools、MCP

很多开发者容易把 Skills 和 Prompt、MCP 搞混。我们用**"餐厅后厨"**做个类比:

概念 餐厅对应物 核心关键词 一句话解释
Prompt 📜 这次点的菜单 一次性指令 "今天我要做这道菜"
Memory 📂 老顾客口味档案 静态事实 "张总不吃辣,厨房有两个灶台"
Tools 🍳 锅、刀、烤箱 动手能力 "我能炒、能切、能烤"
MCP 🔌 供应商标准插头 外部连接通道 "能连隔壁冷链仓库拿食材"
Skills 📖 招牌菜谱 + 经验贴 程序性经验 "先腌 15min,中大火炒,⚠️ 千万别先放盐"

🔍 Skills 到底强在哪?

  • vs Prompt:Prompt 是临时交代,说完就忘;Skills 是制度化沉淀,写一次永久触发。
  • vs Tools:Tools 是动词(手),Skills 是菜谱(脑)。
  • vs MCP:MCP 是管道,Skills 是处理水流的过滤器。

🤖 协作场景:用它们完成"发项目周报"

1. Memory  → 知道你喜欢 Markdown、中文、简洁
2. MCP     → 连上 Jira/数据库,拉取本周数据
3. Tools   → 执行脚本、读写文件、调用 API
4. Skills  → 加载《周报规范》:按"完成/阻塞"分类,⚠️避开敏感数据
5. Prompt  → 临时调整:"这周重点写 A 模块延期原因"

它们不是竞争关系,而是分工协作的完整链路


💰 三、为什么要用 Skills?

📊 算一笔经济账

场景 没有 Skill 有 Skill 节省
对话轮数 8-12 轮 3-5 轮 ↓ 60%
耗时 5-10 分钟 2-3 分钟 ↓ 70%
出错率 高(凭记忆) 低(照手册) ↓ 80%

如果团队每周遇到 10 次复杂任务 → 每月节省 8-12 小时

🎯 六大核心价值

🛡️ 避免重复犯错
上次坑写进 Skill,下次自动避雷
🚀 加速冷启动
新 Session/新人加载 3 个 Skill,10 分钟上手
📚 知识传承
核心人员请假,新人加载 Skill 直接干活
一致性保证
同一任务,同一种做法,同一个结果
💸 成本降低
减少探索试错的 Token 消耗
🔄 经验复用
一次解决,永久受益

⚡️ 黄金法则:流程需要 5+ 次工具调用,或你在反复教同一件事 → 该沉淀为 Skill。


📝 四、Skills 包含了哪些内容?

一份好的 Skill,结构应该像一份严谨的技术 SOP

1️⃣ 基本信息(YAML Frontmatter)

name: deploy-to-test-server
description: 部署前端项目到测试服务器
tags: [frontend, deployment, ops]
related_skills: [git-pr-workflow]

2️⃣ 核心正文(Markdown)

这是"操作手册"的灵魂:

模块 作用 示例
When to use 什么时候触发? "当你需要将前端部署到测试环境时"
Core approach 核心思路与大原则 "先构建,再 SCP,最后重启服务"
Steps 编号步骤(第一步、第二步...) "1. npm run build → 2. scp dist/..."
Pitfalls ⚠️ 踩坑记录(最有价值!) "⚠️ 确认 dist/ 存在再 SCP,否则清空远程!"
Verification 验收清单 "curl 测试页面返回 200 即成功"

3️⃣ 🛠️ 实战演示:30 秒创建一个 Skill

~/.hermes/skills/my-skill/SKILL.md 中写入:

---
name: deploy-to-test
description: 部署 dist 到测试服务器 10.0.1.100
---

## When to use
需要将前端构建产物部署到测试环境时。

## Steps
1. 执行 `npm run build`
2. 确认产物在 `dist/` 目录
3. 执行 `scp -r dist/ user@10.0.1.100:/var/www/app`
4. 执行 `ssh user@10.0.1.100 "systemctl restart app"`

## Pitfalls
- ⚠️ 确认 `dist/` 存在再执行 SCP,否则会清空远程目录!
- ⚠️ IP 是 10.0.1.100(测试),别写成 10.0.1.200(生产)!
🎨 前端日常 Skill 示例
《组件库使用规范》:AntD 表单必填校验怎么写、Modal 关闭时机
《Git Commit 规范》:feat/fix 格式、避免"更新代码"这种废话
《ESLint 排错指南》:常见报错 + 修复步骤合集

⚙️ 五、Skill 运行机制:渐进式披露 + 匹配逻辑

灵魂拷问:如果有 100 个 Skill,每个几千字,智能体每次都要读几十万字的 Skill 全文吗?那 Token 不就爆了吗?

答案是否定的。智能体使用的是一个非常聪明的机制 → 渐进式披露(Progressive Disclosure)

📚 类比:图书馆借书

做法 过程 结果
全量加载 把 100 万本书全搬到桌上让你翻 桌子放不下(Context 爆满),找书极慢
渐进式披露 先看检索卡 → 找到目录 → 只复印相关页 桌面整洁,精准高效

🔍 匹配逻辑详解(核心!)

智能体的匹配不是简单的"关键词搜索",而是基于语义理解的智能匹配

用户输入:"帮我把代码发到测试环境"
         ↓
【阶段一:全局扫描 L1】
智能体读取所有 Skill 的 name + description(仅索引,不读正文)
         ↓
语义匹配引擎分析:
  "发" → deploy / publish / release
  "代码" → frontend / build artifact
  "测试环境" → test / staging / qa
         ↓
匹配结果:
  ✅ deploy-to-test-server  (相关度 95%)
  ✅ publish-to-staging     (相关度 80%)
  ❌ deploy-to-prod         (相关度 10%,排除)
         ↓
【阶段二:按需加载 L2】
调用 skill_view 加载匹配度最高的 Skill 完整正文
仅载入 1-3 个 Skill(约 3-12KB),其余仍停留在索引状态
         ↓
【阶段三:串联调用】
如果命中多个 Skill,按相关度排序执行
通过 related_skills 自动串联(如先跑 lint,再 deploy)
         ↓
【阶段四:依计行事】
结合 SOP + Pitfalls,调用 Tools 执行

💡 匹配的关键特性

特性 说明 示例
语义理解 不是关键词匹配,而是理解意图 搜"部署"能命中 deploy-to-prodrelease-beta
多 Skill 命中 一次可能命中多个,按相关度排序 "部署并通知" → 命中 deploy + notify 两个 Skill
冲突解决 两个 Skill 矛盾时,优先级高的覆盖 deploy-prod 优先级 > deploy-staging
相关度阈值 低于阈值不加载,避免误触发 描述不匹配的 Skill 不会被加载

📊 Token 消耗对比(数据说话)

场景 全量加载 渐进式披露 节省
50 个 Skill 索引 - ~8 KB -
加载 3 个 Skill 正文 - ~12 KB -
全量加载 50 个 Skill 正文 ~250 KB - -
总 Token 消耗 ~250K ~20K ↓ 92%

💡 核心结论:渐进式披露保证了 Skills 规模可无限扩展,但运行成本始终可控


🔧 六、如何提炼你自己的 Skills?

📋 提炼五步法

  1. 回顾对话:成功的关键是什么?卡在哪里?
  2. 提取核心逻辑:把零散指令抽象成编号步骤。
  3. 记录踩坑点:把"⚠️ 注意"写下来。
  4. 写验证方法:列出完成后的检查清单。
  5. 命名、分类、创建:存入 ~/.hermes/skills/

🏷️ 命名规范(非常重要!)

维度 推荐 ✅ 避免 ❌
格式 动词-对象-场景 随意命名
示例 deploy-frontend-prod
parse-natural-query
my-skill
test
v2-final
最终版
语言 英文小写 + 连字符 中文命名(部分框架不支持)
长度 简洁明了(3-5 个词) 过长描述(系统会截断)

🐛 调试 Skill 的 3 种方法

方法 操作 适用场景
手动加载 让智能体 skill_view name,检查是否正确加载 验证 Skill 语法和结构
观察执行 发送触发指令,观察是否按 Steps 执行 验证匹配逻辑和步骤顺序
日志追踪 查看终端日志,确认 L1→L2→L3 各阶段是否正确 深度调试匹配和加载问题

📐 好 Skill 的标准

维度 好 ✅ 差 ❌
触发条件 "当你需要配 CI 流水线时" (没写什么时候用)
步骤 "第一步:xxx,第二步:xxx" "大概就是这样,你看着办"
踩坑记录 "注意:A 和 B 容易搞反" (没有记录)
长度 一屏能看完 写了 20 页没人看
抽象程度 使用 ${ENV_VAR} 占位 硬编码 IP/密码/路径

🏗️ 七、实战案例:项目的 Skill 沉淀

Skill 不是坐在办公室里设计的,而是在代码泥泞中踩坑、填坑、记录长出来的。

📌 项目背景

前端系统,用户希望在图表弹窗用自然语言提问:

  • "计算总和"
  • "导出最近三天数据"
  • 约束:不改后端 API,不破坏原有布局

🔄 从"踩坑"到"沉淀"

🚨 遇到的坑 💡 解决方案 📘 沉淀 Skill
Canvas 读不到数据
AI 看不懂 Canvas 像素
不读 Canvas,从 API 构建标准化 rows 数据集快照捕获
自然语言歧义
"前三天"格式乱
建立关键词分类 + 领域归一化 意图解析与归一化
LLM 算术不准
让 LLM 算总数会幻觉
LLM 只做意图翻译,计算由 JS 执行 LLM 规划 + 本地执行分离
布局破坏
把 AI 塞弹窗,UI 乱了
extra 触发独立悬浮面板 不破坏布局的助手模式

💬 沉淀前后对比

❌ 没有 Skill ✅ 有 Skill
对话 "请问ID 是多少?导出哪些字段?Excel 还是 CSV?"
(反复确认 3 轮)
"已识别【导出】意图。匹配'ID',生成 CSV(含 BOM 头),请查收。"
(一步到位)
耗时 ~5 分钟 ~30 秒
体验 用户烦躁 用户惊喜

🎯 项目 Skill 提炼 Checklist

从项目中沉淀 Skill 时,用这份清单自检:
  • 识别边界:只做一件事(意图解析 / 数据提取),不做万能胶水
  • 抽象输入输出:用"当前页面的数据集"代替"ID",可复用
  • 标记约束:明确写"不要做"的清单(不改后端、不读 Canvas、不破坏布局)
  • 关联 Skill:通过 related_skills 把 dataset/parser/modal 串联,1+1>2
  • 验证可自动化:Checklist 能用 Tools 自动检查,不靠肉眼
🏗️ 架构建议:不要把所有经验塞进一个巨大 Skill。拆分为多个独立 Skill,
通过 `related_skills` 关联。按需加载,解耦复用,正是渐进式披露的最佳场景。

👥 八、团队 Skills 管理:从"个人经验"到"团队资产"

🤔 核心问题:个人 Skills 写得再好,怎么让团队所有人受益?

📦 团队 Skill 共享流程

个人创建 Skill
    ↓
Git 仓库管理(skills-repo)
    ↓
团队 PR 审核(质量把关)
    ↓
hermes skills tap add <team-repo>
    ↓
全员自动同步,版本更新

📋 团队 Skill 编写规范

规范项 要求 示例
命名 团队/领域-动作-对象 frontend/deploy-frontend-prod
分类 按领域分目录(frontend/、backend/、ops/) ~/.hermes/skills/frontend/deploy/
版本 YAML 加 versionupdated_at version: 2.1
审核 提交 PR,至少 1 人 review 避免低质量 Skill 流入
文档 每个 Skill 头部加 @author@since @author: zhangjie @since: 2024-03

🔄 版本管理与变更追踪

---
name: deploy-frontend-prod
description: 部署前端到生产环境
version: 2.1.0
updated_at: 2024-06-15
author: zhangjie
changelog:
  - "v2.1: 新增健康检查步骤"
  - "v2.0: 从单步 SCP 改为蓝绿部署"
  - "v1.0: 初始版本"
---

🎯 新人 Onboarding 实战

新员工入职,10 分钟进入工作状态:

# 1. 安装团队 Skill 包
hermes skills tap add company-handbook

# 2. 自动加载 5 个核心 Skill
✅ 代码规范
✅ 部署流程  
✅ 常用命令
✅ 联系人列表
✅ 故障应急预案

# 3. 新人可以直接问智能体:
# "怎么部署到测试环境?" → 自动加载 Skill,按步骤执行

⚠️ 失败案例:200 个 Skill 的教训

🚨 某团队教训:创建了 200 个 Skill,但无人维护。3 个月后:
• 60% 的 Skill 已失效(接口下线、IP 变更)
• 30% 的 Skill 互相矛盾(同一操作不同步骤)
• 只有 10% 被高频使用

结论:Skill 数量不是 KPI,质量才是。宁缺毋滥,定期清理。

🛡️ 九、维护与常见坑:别让手册变成废纸

⚠️ 警告:不维护的 Skill 比没有 Skill 更危险。它会引导智能体按错误的方式做事。

⚡ 三大常见误区

🚨 误区 💥 症状 ✅ 正确做法
把 Memory 当 Skill 用 在 Skill 里写"用户叫张三" 个人偏好、环境事实放 Memory
单体巨无霸 一个 Skill 写 2000 行,涵盖部署/测试/监控 按职责拆分成 3-5 个小 Skill
写后不管 Skill 里 IP 换了、接口下线,无人更新 建立"谁用谁更新"文化

🔄 Skill 生命周期

发现需求 → 解决问题 → 提炼 Skill → 团队共享 → 使用反馈 → 更新迭代
    ↑                                                              ↓
    └────────────────── 循环改进 ─────────────────────────────────┘

📋 日常维护清单

  • 用了就检查:每次用完后,确认步骤是否还准确
  • 定期清理:项目下线/流程变了,果断删除或归档
  • 安全红线:Skill 里绝不要硬编码密钥、密码或敏感路径
  • 环境变量解耦:用 ${TEST_SERVER_IP} 代替写死的 IP

🔮 十、展望:AI 自动生成 Skill 的未来

Skills 目前主要靠人工沉淀,但这个领域正在快速进化。

趋势 说明 价值
🤖 自动提取 智能体从成功对话自动提取步骤,生成 Skill 草稿 你只需审核确认,大幅降低创建成本
📊 A/B 测试 系统追踪不同 Skill 版本的执行成功率 自动推荐更优版本,持续优化
🔄 动态优化 根据执行反馈,Pitfalls 自动累积、排序 经验自动沉淀,越用越聪明
🌐 Skill 市场 社区共享优质 Skill,一键安装 开箱即用,不必重复造轮子

💡 你现在要做的:先养成手动沉淀的习惯。当 AI 能自动生成 Skill 时,你的审核能力就是最好的壁垒


🎉 结语:给"一次性聪明"注入"永久性智慧"

AI 智能体不是魔法,它是一个拥有无限潜力的**"超级实习生"**。

给它 它获得
Prompt 📜 指令(这次怎么做)
Tools 🍳 手脚(能做什么)
Memory 📂 背景(我是谁)
Skills 📖 经验和灵魂(以后都该这么做)

下次当你解决了一个复杂问题,或者在心里默念"下次别再犯这个错"的时候,停下来,花 5 分钟把它写成一个 Skill。你会发现,你的智能体,正在变得越来越懂你。


📝 全文总结卡片

定义 Skills = 智能体的程序性记忆(操作手册)
区别 Prompt 临时 / Memory 静态 / Tools 能力 / MCP 连接 / Skills 是 SOP 流程
机制 渐进式披露(先索引 → 按需加载 → 执行),成本降 92%
匹配 基于语义理解,不是关键词匹配。多 Skill 命中按相关度排序
价值 一次沉淀,永久复用。避免重复犯错,节省 60-80% 时间
团队 Git 管理 + PR 审核 + 版本追踪。宁缺毋滥,定期清理
行动 下次遇到 5+ 步的复杂任务,把它写成 Skill!

生产力悖论:AI Coding 的效率狂欢与秩序隐忧

作者 橙某人
2026年4月23日 16:47

亲身下河知深浅,亲口尝梨知酸甜,今有感而发,诸位请坐,且听小编一言。

作为长期深耕一线的前端开发者,小编曾是 AI Coding 最坚定的拥护者与狂热追捧者,也真切沉浸在它带来的高效开发体验之中。

AI 极大简化了重复繁琐的编码工作,让功能实现与项目推进速度显著提升,这种肉眼可见的生产力跃升,着实让人难以抗拒、令人着迷。

可随着项目持续迭代、代码规模不断扩张,小编也愈发看清这场效率狂欢背后的深层隐患!

即便始终严格把控代码质量、坚持 Code Review、守护整体架构规范,AI 生成代码带来的体量爆炸、逻辑冗余仍在悄然累积技术负债,无形之中推高了后续维护与迭代成本。

更值得警惕的,是研发能力的隐性退化。开发者越来越依赖 AI 完成技术选型、代码编写甚至问题排查,底层调试能力、原理理解深度、架构判断力不断流失。许多新技术未经消化便直接落地项目,给后续维护与新人接手都埋下了巨大障碍。久而久之,团队会慢慢失去真正解决复杂问题的核心能力,变成只会调用工具的执行者。

更值得深思的是,AI Coding 的浪潮正在模糊研发边界。产品、测试等角色纷纷急于上手生成代码,却忽略了需求打磨、逻辑闭环与业务深度思考。本末倒置之下,残缺的需求、模糊的逻辑反而制造大量内耗,让团队工程秩序逐渐失衡,也背离了技术服务于业务的本质!

不是说 AI Coding 不好,相反,它太好了,好得让人细思极恐。

小编至今仍是它的狂热追捧者,但也愈发明白,工具越智能,越考验人的清醒与思考。

Flutter路由演进路线(2026)

作者 猫山月
2026年4月23日 15:56

目前很多资料都还集中在navigator1.0版本(包括国内某些大模型还在推这个,估计训练的资料还是旧版)。初学者感到困惑,故整理之~

🧭 结论

总的来说,路由方案经历了多次迭代,目前是Navigator 2.0 。

  • Navigator 1.0 时代的 API 比较原始,社区各自摸索出命令式写法generateRoute 集中管理两条路。

  • Navigator 2.0 出来后底层 API 虽然更强大但写起来繁琐,官方随后封装了 go_router 来填平这个坑。

现在的稳定选型就是 go_router + 文件结构:

  • routers.dart 作为入口,配置 GoRouter 实例
  • routes.dart 按模块拆分路径常量或 GoRoute 列表

这套结构对大型项目同样够用,因为 go_router 本身支持嵌套路由、重定向、守卫、ShellRoute 等复杂场景,拆模块只是组织代码的问题,不是能力上的限制。

前沿三个方案的关注点各有侧重:

  • go_router_builder —— 用代码生成取代手写字符串路由,类型安全,代价是要学注解+跑 build_runner
  • autoRoute —— 内置路由守卫和鉴权拦截机制,安全需求强的 app 更适合
  • go_router_sugar —— 纯语法糖,没有新能力,看项目风格取舍

flutter_router_evolution.svg

🗺️ Flutter 路由技术演进全景图

阶段 核心技术 核心特点 当前状态
Navigator 1.0 命令式 API (push/pop) + 静态路由表 (routes) + onGenerateRoute 简单直接,适合小应用;但无法与系统路由(如浏览器URL)深度集成。 已过时,新项目不建议使用。
Navigator 2.0 Router + RouterDelegate + RouteInformationParser 声明式、与系统深度集成,但API极其复杂,需手写大量样板代码。 底层基石,但不直接使用。
声明式封装时代 go_router (官方推荐) / auto_route (社区流行) 在 Navigator 2.0 之上封装,提供简洁声明式 API。 当前主流,生产项目首选。
类型安全增强时代 go_router_builder / auto_route (自带生成器) / go_router_sugar 在声明式基础上,通过代码生成实现完全类型安全,消除字符串路径。 前沿方案,大中型项目可渐进式引入。

Navigator 1.0

Navigator 1.0 声明式路由

// lib/main.dart
MaterialApp(
  initialRoute: '/login',
  routes: {
    '/login': (context) => LoginScreen(),
    '/home': (context) => HomeScreen(),
  },
)

局限性:

  • 无法传递参数routes 表的值是一个固定的 WidgetBuilder,无法动态传入 arguments
  • 无法实现路由守卫:比如“未登录时强制跳转登录页”,需要在每个页面 initState 里重复写逻辑。
  • 无法处理未知路由:用户输入 /abc 会直接报错。

onGenerateRoute

onGenerateRoute是Navigator 1.0时代解决传参、守卫、404 页面等问题的方案

MaterialApp(
  initialRoute: '/',
  onGenerateRoute: (settings) {
    // 获取传递的参数
    final args = settings.arguments;
    
    // 路由守卫逻辑
    final isLoggedIn = StorageUtils.getTokenSync(); // 假设你有同步读取方法
    
    switch (settings.name) {
      case '/':
        return MaterialPageRoute(builder: (_) => const SplashScreen());
      case '/login':
        return MaterialPageRoute(builder: (_) => const LoginScreen());
      case '/home':
        if (!isLoggedIn) {
          return MaterialPageRoute(builder: (_) => const LoginScreen());
        }
        return MaterialPageRoute(builder: (_) => const HomeScreen());
      default:
        return MaterialPageRoute(builder: (_) => const NotFoundScreen());
    }
  },
)

Navigator 2.0

Navigator 2.0 出来后底层 API 虽然更强大但写起来繁琐,官方随后封装了 go_router 来填平这个坑。

🧱 推荐的项目结构

lib/
├── main.dart                 # 入口,只负责 runApp 和顶层 Provider
├── app.dart                  # MaterialApp.router 的主体(可选进一步拆分)
├── router/
│   ├── router.dart           # GoRouter 实例和路由表定义
│   └── routes.dart           # 路由路径常量(避免硬编码字符串)
├── screens/
│   ├── login_screen.dart
│   └── home_screen.dart
└── utils/
    └── storage_utils.dart

📝 具体抽离示例

1. 路径常量文件 lib/router/routes.dart

class AppRoutes {
  static const String login = '/login';
  static const String home = '/home';
  // 后续可加:static String userDetails(String id) => '/user/$id';
}

2. 路由配置文件 lib/router/router.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../screens/login_screen.dart';
import '../screens/home_screen.dart';
import '../utils/storage_utils.dart';
import 'routes.dart';

class AppRouter {
  static final GoRouter router = GoRouter(
    initialLocation: AppRoutes.login, // 初始值可动态计算,但建议在 redirect 中统一处理
    routes: [
      GoRoute(
        path: AppRoutes.login,
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: AppRoutes.home,
        builder: (context, state) => const HomeScreen(),
      ),
    ],
    redirect: _redirect,
  );

  static Future<String?> _redirect(BuildContext context, GoRouterState state) async {
    final token = await StorageUtils.getToken();
    final isLoggedIn = token != null;
    final isGoingToLogin = state.matchedLocation == AppRoutes.login;

    // 未登录且不在登录页 → 强制去登录
    if (!isLoggedIn && !isGoingToLogin) {
      return AppRoutes.login;
    }
    // 已登录却要去登录页 → 重定向到首页
    if (isLoggedIn && isGoingToLogin) {
      return AppRoutes.home;
    }
    return null;
  }
}

3. 简化后的 main.dart

import 'package:flutter/material.dart';
import 'router/router.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Login Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      routerConfig: AppRouter.router,
    );
  }
}

未来关注方向

方案 定位 行动建议
go_router_builder go_router 官方增强插件,提供类型安全。 保持关注,当页面超过 50+ 或传参频繁出错时,可平滑迁移。
auto_route(第三方) 社区老牌路由方案,功能极强但略有学习曲线。 了解即可,如果团队已熟悉 go_router,无需切换。
go_router_sugar 实验性项目,通过文件系统路由自动生成。 仅作视野拓展,尚未达到生产可用状态。

Threejs,地图标签绘制,碰撞检测逻辑

作者 月寒孤箫
2026年4月23日 15:30

地图标签排布与碰撞检测逻辑文档

本文档详细描述了 3D 地图场景中 HTML 标签(CSS2DObject)的自动排布、碰撞检测及响应式适配逻辑。


1. 核心设计思想

地图标签系统旨在解决高密度点位下的可视化问题。当多个场站图标聚集在一起时,系统通过**“就近探测”“边缘重分布”**两套策略,确保标签既不互相重叠,也不遮挡地图关键点位,并自动在小屏幕下切换紧凑布局。


2. 响应式与全局配置

系统通过文件顶部的配置对象进行全局控制:

2.1 RESPONSIVE_CONFIG (响应式配置)

根据 window.innerWidth < 2000px (1K屏幕) 自动切换参数:

  • labelHeight / labelBaseWidth:定义标签在碰撞计算中占用的矩形尺寸。
  • visualGap:视觉上的偏移间距(标签离图标多远)。
  • collisionGap:碰撞检测时的敏感间距(用于判定是否重叠)。
  • marginX / marginY:边缘分布时,标签离地图包围盒的距离(即连线长度)。
  • getStep():动态计算标签排队的步长,标签越多,间距越紧凑。

2.2 COLLISION_CONFIG (碰撞策略)

  • checkIconCollision:布尔值。若开启,标签会避开地图上的图标;若关闭,标签只在互相之间避让,从而减少连线外排的情况。

3. 标签排布流程

第一阶段:多方位就近尝试 (Local Probing)

对于每一个场站,程序按以下优先级尝试四个方位:

  1. 右侧 -> 2. 左侧 -> 3. 下方 -> 4. 上方

检测逻辑

  • 在每个方位构建一个虚拟的“探测矩形”(基于 collisionGap)。
  • 执行碰撞检测(见第 4 节)。
  • 若不冲突:直接在此方位渲染标签,不显示指引连线。
  • 若全部冲突:进入第二阶段。

第二阶段:边缘重分布 (Edge Redistribution)

当就近位置均不可用时,标签会被分配到地图的四个边缘区域:

  1. 象限划分:根据场站相对于地图中心的角度,将其划分为 左、右、上、下 四个组。
  2. 抗交叉排序:对每个组内的标签按坐标排序,确保指引连线在从内向外拉伸时互不交叉。
  3. 对齐渲染:按照 getStep() 计算出的动态间距,将标签均匀排列在 marginX/marginY 划定的边界线上,并绘制折线。

4. 碰撞检测算法 (Collision Algorithm)

系统采用 AABB 矩形碰撞检测 算法。判定冲突的条件如下:

  1. 标签 vs 图标:当前标签矩形是否覆盖了地图上其他任何一个场站图标的“敏感区域”。
  2. 标签 vs 标签:当前标签矩形是否与已经确定位置的(已渲染的)其他标签矩形重叠。

5. 交互与同步

  • 自动重绘 (Resize):系统监听 window.resize 事件,采用 200ms 防抖,在窗口大小变化后自动重新计算全图标签的排布。
  • 视觉微调:通过 CSS transform 进行 1px 的向上微调,以在视觉上修正由于文字垂直重心导致的对齐偏差,该微调不参与物理碰撞计算。

6. 参数微调指南

若需调整表现,建议按以下顺序操作:

  • 若连线太长:减小 marginXmarginY
  • 若标签排得太挤:调大 getStep 中的 base 值。
  • 若想让更多标签留在地图内
    • 调小 collisionGap
    • checkIconCollision 设为 false
    • 调小 RESPONSIVE_CONFIG 中的 labelHeight

7. 核心逻辑实现 (伪代码)

以下展示了 setLabel 方法内部的核心处理逻辑:

function setLabel(data) {
  // 1. 初始化与投影
  const projectedPoints = data.map(p => project(p.position));
  const occupiedRects = []; // 存储已确定的标签位置
  const iconRects = projectedPoints.map(p => getIconRect(p));

  // 2. 遍历所有点位进行排布尝试
  projectedPoints.forEach(p => {
    const labelSize = getResponsiveLabelSize(p.name);
    let found = false;

    // 策略 A: 多方位就近尝试 (右、左、下、上)
    const probeDirections = [
      { dx: visualGap + labelW/2, dy: 0, collisionDx: collisionGap + ... },
      { dx: -(visualGap + labelW/2), dy: 0, ... },
      ...
    ];

    for (const dir of probeDirections) {
      const tryRect = createRect(p.x + dir.collisionDx, p.y + dir.collisionDy, labelSize);
      
      // 执行碰撞检测
      if (!isColliding(tryRect, iconRects) && !isColliding(tryRect, occupiedRects)) {
        renderLabel(p, dir.offset); // 原地渲染
        occupiedRects.push(tryRect);
        found = true;
        break;
      }
    }

    // 策略 B: 若就近全部碰撞,则存入待外排列表
    if (!found) fallbackPoints.push(p);
  });

  // 3. 处理外排标签 (边缘分布逻辑)
  const quadrants = splitToQuadrants(fallbackPoints); // 划分为 左/右/上/下 四个组
  
  quadrants.forEach(group => {
    group.sort(); // 排序防止连线交叉
    const step = getDynamicStep(group.length); // 获取压缩后的间距
    
    group.forEach((p, index) => {
      const finalPos = calculateEdgePos(index, step);
      renderLabelWithLine(p, finalPos); // 渲染带指引线的标签
    });
  });
}

/**
 * 矩形碰撞检测 AABB
 */
function isColliding(r1, r2) {
  return !(r1.x + r1.w < r2.x || r2.x + r2.w < r1.x || 
           r1.y + r1.h < r2.y || r2.y + r2.h < r1.y);
}

Hermes Agent 直连飞书机器人

作者 袋鱼不重
2026年4月23日 15:29

适用场景:

  • 你已经安装好了 Hermes Agent
  • 你想直接让 Hermes 接飞书

前提条件,至少确认这些条件成立:

  • hermes --version 能正常输出版本
  • 飞书开放平台可以创建企业自建应用
  • 你的飞书账号能访问该应用
  • 已有可用的推理 provider
  • 如果你要走 GPT 而不是 MiniMax,需要额外满足:
    • hermes auth list 里有可用的 openai-codex 凭据

1. 创建飞书应用,开通对应权限及长连接

(注意:应用发布需要企业管理员通过,自己的账号测试可以通过自己创建的企业来进行操作)

  1. 在非书中搜索,打开开发者小助手,进入开发者后台

  1. 创建应用

  1. 添加机器人能力

  1. 开通对应权限

注意要打开的权限:

权限 作用
im:message.receive_v1 核心!接收用户发给机器人的单聊 / 群聊消息
im:message:send_as_bot/ application:bot:send_message 机器人回复消息、发卡片 / 文本
im:message.p2p_msg:readonly 读取用户单聊发给机器人的消息内容
im:message.group_at_msg:readonly 读取群里 @机器人的消息内容
im:chat:readonly 读取会话信息(群 / 单聊)
contact:user.base:readonly 读取用户基础信息(昵称 / ID,Hermes 做用户识别用)

  1. 打开事件与回调的长连接

  1. 创建版本,发布应用

  1. 发布完成,会收到消息。点击打开应用

就可以看到机器人聊天框了

2. Hermes连接飞书

  1. Windows 上先处理编码

在原生 Windows PowerShell 里,Hermes 很容易因为 GBK 编码报错。先执行:

chcp 65001
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$env:PYTHONIOENCODING='utf-8'

后面的 hermes 命令都建议在这个终端里执行。

  1. 确认 Hermes 已安装
hermes --version
hermes doctor

3. 启动飞书接入向导

hermes gateway setup

选择 Feishu / Lark

因为我们前面已经创建好了机器人,选择 2,到飞书开发者后台复制 APP ID 和 密钥(注意,密钥是不会显示的,点击复制按钮后回到hermes控制台直接鼠标右键回车即可)

省略了第一步的话,可以优先选择:

  • Scan QR code to create a new bot automatically

如果二维码流程失败,再改成手动输入 App ID / App Secret

接着会出现两个选项:

选项 含义 适用场景
1. feishu (China) 飞书(国内版) 国内版飞书(feishu.cn域名,国内公司常用)
2. lark (International) Lark(国际版) 国际版飞书(larksuite.com域名,海外公司 / 团队常用)

我这里用的是国内版,选 1

  1. 选择 Connection mode

建议选:

  • WebSocket

原因:

  • 本机直连最简单
  • 不需要公网域名
  • 不需要 HTTPS 回调地址
  • 不需要自己暴露 webhook 服务

Webhook 只适合你已经有公网可访问的 HTTP/HTTPS 服务时使用。

  1. Direct messages authorization

建议选:

  • Use DM pairing approval

这表示:

  • 第一次有人私聊机器人,先给出 pairing code
  • 机器人管理员审批后,这个用户才能继续使用

这是最适合个人测试和小范围使用的策略。

  1. Group chats handling

建议选:

  • Respond only when @mentioned in groups

这样群里只有 @机器人 时才会回复,避免乱接话。

  1. Home chat ID

这个可以先留空。

它只影响:

  • cron 结果发送到哪里
  • 跨平台通知发到哪里

接入飞书聊天本身不依赖这个值。

  1. 回车完成

3. 验证

4. 切换模型

  1. 先确认权限列表
hermes auth list

我这有两个模型:MiniMax-M2.7openai-codex

  1. 执行以下命令

MiniMax-M2.7,走 GPT,把 provider 改成 openai-codex

chcp 65001
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$env:PYTHONIOENCODING='utf-8'

hermes config set model.provider openai-codex
hermes config set model.base_url https://chatgpt.com/backend-api/codex
hermes config set model.default gpt-5.4-mini

3. 验证本地 Hermes 是否真的修改

hermes config show

正确的结果会包含:

provider: openai-codex
default: gpt-5.4-mini
base_url: https://chatgpt.com/backend-api/codex

  1. 切换完成后需要重置会话,通过在飞书里对机器人发送:
/reset

或者:

/new

可以通过 hermes chat -Q -q "Reply with OK only." --provider openai-codex -m gpt-5.4-mini 查看当前会话ID,回复OK说明没问题

5. 结论

  • 飞书聊天会保存到 Hermes 的 session 记录中
  • 这不等于每条聊天都会自动写入长期记忆 MEMORY.md / USER.md
  • 飞书文本聊天支持
  • 飞书群里 @机器人 聊天支持
  • 飞书语音/视频通话、飞书会议实时语音对话,当前不作为已验证能力

要写入长期记忆可以在飞书中名且说:

记住:以后默认用中文回答,默认使用 GPT-5.4-mini。把这两条写入长期记忆。

验证是否成功:

  • 看 memories 目录 里是否生成了 MEMORY.md / USER.md
  • /reset 后再问它:以后默认用什么模型/语言?
  • 如果它能答出你刚保存的偏好,就说明长期记忆已经生效了

如果你只想快速跑通,按这套最短路径:

  1. 用 UTF-8 PowerShell
  2. hermes gateway setup
  3. Feishu / Lark
  4. WebSocket
  5. DM pairing approval
  6. 群里只有 @机器人 才回复
  7. Home chat ID 留空
  8. 飞书后台发布版本
  9. 运行 hermes gateway run
  10. 飞书里给机器人发 hi
  11. 本机执行 hermes pairing approve feishu <code>
  12. 飞书里再发 你好
  13. 如需 GPT,改 config.yamlopenai-codex + gpt-5.4-mini
  14. /reset
  15. 再问 你现在是什么模型

满足下面三项,就说明这条链路已经跑通:

  • 飞书里机器人能收消息并回复
  • hermes sessions list 能看到新 session
  • agent.log 里能看到实际使用的 provider/model

如果你要继续完善,可以再做这几件事:

  • 给飞书机器人加几个快捷菜单
  • /sethome 配置默认通知会话
  • 把 gateway 放到更稳定的长期运行环境
  • 补充飞书应用权限和事件配置

从 0 到上线:我如何用开源打造一款密码管理 Chrome 插件

作者 _白_
2026年4月23日 15:27

从 0 到上线:我如何用开源制打造一款密码管理 Chrome 插件

当 Google 密码管理器对内部系统无能为力时,我决定自己造一个轮子

前言

作为一个每天要登录十几个不同系统的开发者,我一直依赖 Chrome 自带的密码管理功能。它很方便,能自动记住密码、跨设备同步,直到我遇到了公司内部系统。

那些运行在 10.xxx.xxx.xxx:8080 上的老旧管理后台、内部 Governance 平台、BPM 工作流引擎……Chrome 要么压根不弹出保存密码的提示,要么保存了也无法自动填充。更糟的是,这些系统的密码还必须每 90 天更换一次,手动输入成了每天的痛苦。

我问自己:能不能做一个完全本地化、能识别任何表单、还能跨设备安全同步的密码管理器?

于是,这个开源项目诞生了。


一、项目定位与核心功能

我需要的是一个不依赖任何云服务、数据完全掌握在自己手里、且能暴力识别所有登录表单的 Chrome 扩展。

最终成品包含以下核心能力:

功能 描述
🔐 主密码加密 所有密码使用用户主密码 AES 类加密(示例实现),离开主密码无法解密
🧠 智能表单识别 通过多种 CSS 选择器暴力匹配用户名/密码输入框,支持独立输入框
⚡ 一键填充 在当前网站列出所有匹配账号,支持多选填充
💾 本地存储 数据仅存在 chrome.storage.local,不上传任何服务器
⏱️ 定时备份 按小时/天/周自动导出加密数据到下载文件夹
🔍 密码库搜索 实时过滤网站/用户名/备注

技术栈:Chrome Extension Manifest V3 + 原生 JavaScript + Chrome Storage API + Alarms API + Downloads API


二、痛点驱动:为什么 Google 记不住内部系统?

Chrome 的密码管理器依赖标准的 <form> 结构和特定的 input 属性(如 autocomplete="username")。但企业内部系统往往:

  • 使用非标准表单(甚至没有 <form> 包裹)
  • 字段命名随意(logonidemployeeNoj_username
  • 采用框架动态生成的输入框(React/Vue 异步渲染)

我的插件必须能“强行”识别这些野路子表单

解决方案:暴力选择器 + DOM 监听

content.js 中,我定义了一组“万能选择器”:

const usernameSelectors = [
  'input[type="text"]',
  'input[type="email"]',
  'input[name*="user"]',
  'input[name*="email"]',
  'input[placeholder*="用户名" i]',
  'input[autocomplete="username"]',
  // ... 共十几种
];

遍历页面所有 <form> 以及独立输入框,只要同时匹配到用户名字段和密码字段,就判定为登录表单。同时利用 MutationObserver 监听 DOM 变化,单页应用动态添加的表单也能实时捕获。


三、架构设计

3.1 扩展的四个主要部分

password-manager-extension/
├── manifest.json          # 扩展配置(权限、脚本注入)
├── popup.html/js          # 弹出窗口 UI,用户主要交互界面
├── background.js          # Service Worker,管理定时器、消息路由
├── content.js             # 注入到网页的内容脚本,负责表单检测和填充
├── options.html/js        # 设置页面(主密码、备份、自动开关)

3.2 数据流

  1. 保存密码:用户在 popup 手动添加或通过 content 检测表单保存 → popup 调用 chrome.storage.local.set 存储加密后的数据。
  2. 填充密码:用户点击“填充” → popup 向当前 tab 的 content script 发送消息(包含解密后的用户名/密码)→ content script 操作真实 DOM 输入值并触发事件。
  3. 自动备份:background 中设定 chrome.alarms 定时器 → 到期后读取存储数据 → 生成 JSON 文件 → 调用 chrome.downloads.download 保存到本地。

3.3 安全性考虑

  • 主密码从不保存明文:用户设置主密码时,我们存储的是 simpleHash(masterPassword),加密时用该哈希值作为密钥。
  • 加密算法:示例中使用 Base64 + 反转字符串(生产环境务必替换为 Web Crypto API 的 AES-GCM)。
  • 备份文件:仅包含加密后的密码数据,不包含主密码。即使文件泄露,没有主密码也无法解密。

四、关键实现细节

4.1 表单检测的核心算法

content.js 中的 detectLoginForms(returnDOMElements) 函数:

function detectLoginForms(returnDOMElements) {
  const detectedForms = [];
  const forms = document.querySelectorAll('form');
  
  forms.forEach((form) => {
    const usernameInputs = findUsernameInputs(form, returnDOMElements);
    const passwordInputs = findPasswordInputs(form, returnDOMElements);
    
    if (usernameInputs.length && passwordInputs.length) {
      detectedForms.push({
        website: document.title,
        url: location.href,
        usernameInputs,
        passwordInputs,
      });
    }
  });
  
  // 若没有找到 form 内的,再查找独立输入框...
  return detectedForms;
}

findUsernameInputs 内部遍历上述选择器数组,收集所有匹配的 DOM 元素(或序列化后的信息,取决于 returnDOMElements 标志)。
这个标志很关键:用于 UI 展示时,我们只需要字段元数据;用于实际填充时,我们必须拿到真实 DOM 元素才能赋值

4.2 自动填充如何“骗”过现代前端框架?

很多 React/Vue 组件监听 inputchange 事件,直接修改 input.value 不会触发框架更新。因此填充时需要手动 dispatch 事件:

usernameInput.value = credential.username;
usernameInput.dispatchEvent(new Event('focus', { bubbles: true }));
usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
usernameInput.dispatchEvent(new Event('change', { bubbles: true }));
usernameInput.dispatchEvent(new Event('blur', { bubbles: true }));

这四种事件基本覆盖了绝大多数框架的响应机制。

4.3 定时备份的实现(background.js)

chrome.alarms.create('autoBackup', {
  periodInMinutes: intervalMap[backupInterval],
  delayInMinutes: 1
});

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'autoBackup') performBackup();
});

function performBackup() {
  chrome.storage.local.get(['passwords'], (result) => {
    const backupData = {
      passwords: result.passwords,
      exportedAt: new Date().toISOString(),
      version: '1.0'
    };
    const blob = new Blob([JSON.stringify(backupData, null, 2)]);
    const url = URL.createObjectURL(blob);
    chrome.downloads.download({
      url: url,
      filename: `password_backup_${timestamp}.json`,
      saveAs: false
    });
  });
}

注意:需要 "permissions": ["alarms", "downloads"]


五、那些踩过的坑与解决方案

坑 1:内容脚本与弹出窗口通信时,dispatchEvent is not a function

原因:在 detectForms 返回给 popup 时,我序列化了 DOM 元素为普通对象,导致 popup 中拿到的是 {name, type, id...} 而不是真实元素。然后 popup 直接对这些对象调用 .dispatchEvent,自然报错。

解决:增加 returnDOMElements 参数。在填充流程中,content script 自己重新调用 detectLoginForms(true) 获取真实 DOM 元素,而不是依赖 popup 传过来的序列化数据。

坑 2:某些网站的表单是动态加载的(如单页应用)

解决:使用 MutationObserver 监听 document.body 的子节点变化,当新增节点时重新执行一次表单检测。

坑 3:定时备份不触发

原因:Service Worker 可能被浏览器挂起,chrome.alarms 虽能唤醒,但需确保在 chrome.runtime.onInstalled 中注册,并在每次设置变化时重新创建 alarm。

解决:在 background.js 中监听 onInstalled 和来自 options 页面的 updateBackupSchedule 消息,每次先 clearcreate

坑 4:密码列表长 URL 导致界面溢出

解决:CSS 中添加 word-break: break-all,同时为长文本添加 title 属性,悬停显示完整内容。


六、如何安装与使用?

开发者模式安装

  1. 下载源码文件夹(包含 manifest.json 等)。
  2. GitHub 访问 https://github.com/qingjie-li/password-manager下载本地 。
  3. Google设置 → 管理扩展程序 → 开启“开发者模式” → 点击“加载已解压的扩展程序” → 选择文件夹。

快速上手流程

  1. 设置主密码:打开扩展弹出窗口 → 设置标签 → 输入主密码。
  2. 添加密码:访问任意登录页 → 自动识别标签 → 开始检测 → 保存检测到的凭据。
  3. 填充密码:再次访问该网站 → 当前网站标签 → 选择账号 → 点击填充。

七、开源与未来计划

项目完全开源,代码可在此仓库找到(链接略)。欢迎 PR。

后续计划:

  • 使用 Web Crypto API 替换当前简易加密
  • 支持通过 WebDAV/坚果云 跨设备同步加密数据(可选)
  • 增加导入 Chrome 原生密码 CSV 的功能

八、总结

做这个插件的最大感受是:“痛点是最好的产品经理”。当 Google 密码管理器无法满足我那些“野路子”内部系统时,自己动手造一个反而更高效。

如果你也受困于公司内部系统的密码管理,不妨试试这个插件,或者基于这个思路自己定制。代码是开源的,欢迎一起完善。


本文首发于掘金,作者:一杯猫 如果觉得有用,请点个赞 ❤️,让更多被内部系统折磨的开发者看到。

❌
❌