普通视图

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

深入理解 Node.js:生态体系与事件循环机制详解

作者 往日种种
2026年4月2日 09:43

深入理解 Node.js:生态体系与事件循环机制详解

Node.js 的诞生彻底打破了 JavaScript 仅能运行在浏览器端的边界,依托 Chrome V8 引擎,它将 JavaScript 带入服务器端开发领域,凭借轻量、高效、生态丰富的特性,成为大前端全栈开发(尤其是 BFF 层)的主流技术选型。本文将从 Node.js 核心特性、生态体系出发,深入解析其核心的事件循环机制,结合实操代码让读者理解其高并发能力的底层逻辑。

一、Node.js 核心特性与生态体系

1. 核心特性:异步无阻塞与单线程高并发

不同于 Java/Go 等多线程语言,Node.js 采用单线程 + 异步无阻塞 I/O 架构,这是其核心优势所在:

  • 单线程的优势:避免了多线程上下文切换的开销,代码逻辑更简单,开发和调试成本更低;
  • 异步无阻塞:对于文件 I/O、网络请求、数据库操作等耗时任务,Node.js 不会阻塞线程等待结果,而是将任务放入事件循环(Event Loop),立刻切换处理新的请求。少量线程即可支撑成千上万的并发连接,服务器资源开销仅为 Java 的一半,这也是 Node.js 高并发能力的核心来源。

2. 核心模块:夯实底层能力

Node.js 内置了丰富的核心模块,覆盖开发核心场景,也是开发者必须掌握的基础:

  • 文件模块(fs) :支持文件的同步 / 异步读写,以及流式处理。例如readFile/writeFile可通过promisify转换为 Promise 风格(避免回调地狱),而readFileSync则是阻塞式读取;createReadStream结合pipe可实现大文件的流式输出,避免一次性加载大文件占用过多内存;
  • 路径模块(path) :处理不同操作系统的路径兼容问题,简化路径拼接、解析等操作;
  • HTTP 模块:原生支持 HTTP 服务搭建,是后续框架的底层基础。

3. 框架生态:从轻量到企业级

Node.js 的框架生态满足不同层级的开发需求:

  • 轻量框架:Express、Koa 是前端开发者入门后端的首选,轻量、灵活,适合快速搭建接口、Mock 服务或 BFF 层应用;
  • 企业级框架:NestJS 基于 TypeScript 开发,天然支持模块化、依赖注入,贴合后端工程化理念,适合大型项目、AI 网关等企业级场景开发。

4. 应用场景与周边生态

Node.js 的生态覆盖全栈开发核心场景:

  • 核心场景:接口转发、实时通信(如 WebSocket)、AI 网关、管理后台开发;
  • BFF 层(Backend For Frontend) :前端开发者可基于 Node.js 封装 Go/Java 后端接口,适配前端业务需求,提升前后端协作效率;
  • 数据库与 ORM:主流适配 MySQL、PostgreSQL,结合 Prisma 等 ORM 工具,简化数据库操作,提升开发效率;
  • AI 开发生态:基于 LangChain 可封装 LLM 调用、构建工具调用(Tool),实现 Agent 开发流程;结合 RAG 技术(文档切分、向量化、向量数据库存储、检索增强),可快速搭建知识库问答系统。

二、Node.js 事件循环机制深度解析

事件循环(Event Loop)是 Node.js 实现异步编程的核心机制,它决定了异步任务的执行顺序。虽然 Node.js 和浏览器的事件循环本质都是 “事件驱动的异步模型”,但因运行环境(服务器 vs 浏览器)不同,实现细节差异显著。

1. 事件循环的核心逻辑

Node.js 的事件循环是一个 “无限循环”,其核心是:同步代码执行完毕后,按阶段依次处理异步任务,每个阶段都有专属的任务队列,且每个阶段执行完后会清空对应微任务队列

2. Node.js 事件循环的核心阶段

Node.js 的事件循环分为多个核心阶段,按执行顺序依次为:

  • timers 阶段:执行setTimeoutsetInterval调度的回调函数,注意setTimeout(fn, 0)并非立即执行,而是等待 timers 阶段触发;
  • poll 阶段:处理 I/O 异步任务(文件、网络、数据库等),是事件循环中最核心的阶段。若 poll 队列有任务,则依次执行;若队列空,则会等待新的 I/O 任务进入,或跳转到 check 阶段;
  • check 阶段:执行setImmediate调度的回调函数,在 poll 阶段空闲后 “强制触发”;
  • 其他阶段(如 idle、prepare、close callbacks):主要为内部逻辑服务,开发者无需重点关注。

3. 微任务队列:优先级高于宏任务

Node.js 的微任务队列包含两类,优先级从高到低为:

  • process.nextTick:独立于事件循环阶段,优先级最高,同步代码执行完后立即执行;
  • Promise 微任务(如Promise.resolve().then()):优先级低于process.nextTick,但高于所有宏任务(timers、poll、check)。

4. 浏览器 vs Node.js 事件循环

  • 浏览器事件循环:核心是 “宏任务→清空微任务→渲染→下一轮宏任务”,宏任务包含 script、setTimeout,微任务仅关注 Promise;
  • Node.js 事件循环:核心是 “多阶段调度”,每个阶段执行完后清空微任务队列,微任务包含process.nextTick和 Promise,且阶段划分更细(适配服务器的文件、网络 I/O 场景)。

5. 代码实例:拆解事件循环执行顺序

结合以下代码,我们逐行分析执行流程,理解事件循环的执行逻辑:

const fs = require('fs')

console.log('start')
// timers 阶段
setTimeout(() => {
  console.log('timeout')
}, 0)
// check 阶段
setImmediate(() => {
  console.log('immediate')
})
// poll 阶段
fs.readFile(__filename, () => {
  console.log('readFile')

  setTimeout(() => {
    console.log('timeout in I/O')
  }, 0)

  setImmediate(() => {
    console.log('immediate in I/O')
  })
})
// microtask 
Promise.resolve().then(() => {
  console.log('promise')
})
// microtask 优先级高于promise
process.nextTick(() => {
  console.log('nextTick')
})

console.log('end')
执行步骤拆解:
  1. 同步代码执行

    • 先执行console.log('start'),输出start
    • 遇到setTimeout(timers 阶段)、setImmediate(check 阶段)、fs.readFile(poll 阶段),均为异步任务,放入对应队列;
    • 遇到Promise.resolve().then()process.nextTick(),放入微任务队列;
    • 执行console.log('end'),输出end
  2. 同步代码执行完毕,清空微任务队列

    • 先执行process.nextTick,输出nextTick
    • 再执行 Promise 微任务,输出promise
  3. 进入事件循环的 timers 阶段

    • 执行setTimeout(fn, 0)的回调,输出timeout(注:若程序启动耗时极短,timers 阶段会优先执行;若耗时稍长,可能先进入 poll 阶段)。
  4. 进入 poll 阶段

    • 等待fs.readFile执行完成,触发回调函数,输出readFile
    • 回调内的setTimeout(timers)和setImmediate(check)被加入对应队列;
    • 因 poll 阶段执行完 I/O 回调后,会优先跳转到 check 阶段,因此先执行setImmediate,输出immediate in I/O
    • 下一轮事件循环的 timers 阶段,执行setTimeout回调,输出timeout in I/O
  5. check 阶段

    • 执行最外层的setImmediate回调,输出immediate(注:若 timers 阶段先执行,check 阶段会后执行)。
最终输出顺序(核心逻辑):
start
end
nextTick
promise
timeout
immediate
readFile
immediate in I/O
timeout in I/O

(注:timeoutimmediate的顺序可能因程序启动耗时略有波动,但 I/O 回调内的immediate in I/O一定早于timeout in I/O

三、理解事件循环的实践意义

掌握 Node.js 事件循环机制,是写出高性能异步代码的关键:

  1. 避免异步任务执行顺序问题:例如明确process.nextTick和 Promise 的优先级,避免回调执行顺序不符合预期;
  2. 优化性能:理解 poll 阶段的阻塞逻辑,避免 I/O 任务堆积导致事件循环卡顿;
  3. 排查问题:定位异步代码的执行延迟、内存泄漏等问题,例如区分 timers 和 check 阶段的任务调度差异。

总结

Node.js 凭借异步无阻塞、单线程高并发的特性,以及丰富的生态(从轻量框架 Express 到企业级框架 NestJS,从基础文件操作到 AI Agent 开发),成为全栈开发的核心技术。而事件循环作为其异步模型的核心,决定了异步任务的执行顺序,理解其阶段划分、微任务优先级,才能真正发挥 Node.js 的高并发优势。无论是接口转发、BFF 层开发,还是 AI 网关、Agent 系统搭建,掌握 Node.js 的核心特性与事件循环机制,都是开发者必备的能力。

Spec Kit:让 AI 编程从 Vibe Coding 到 Spec First

作者 清汤饺子
2026年4月2日 09:19

Hi~大家好呀,我是清汤饺子。

先说个我踩过不止一次的坑。

我要加个功能,跟 AI 说"加个用户登录"。AI 热情开干,半小时后给我整了个完整的 Auth0 集成方案——OAuth 2.0、JWT、refresh token,应有尽有。

我只想要一个最简单的本地账号密码登录。

AI 很委屈:你不就说"加个用户登录"吗?我以为……

我:我也以为你能读懂我的心思。

问题出在哪?不是 AI 不够努力,是我们在起点就没有对齐"做什么"。

然后我发现了 Spec Kit——GitHub 官方的 Spec-Driven Development 工具包。

GitHub 说:为什么不反过来试试?让规格说明书变成可执行的,代码来服务规格,而不是规格服务代码。 听起来很简单对吧?但背后的思路很根本。

一、Spec-Driven Development:规格说明书才是老大

传统开发里,代码是老大,规格说明书是跟班。 我们写 PRD、设计文档、技术方案,这些是"脚手架"——搭完就扔。代码往前走,文档跟不上,最后代码就是规格,规格就是代码。 这句话听起来很熟悉对吧?每个团队都经历过"代码改了三版,文档还停在 v0.1"的痛苦。

我们写 PRD、设计文档、技术方案,这些是" scaffolding "——搭完就扔。代码往前走,文档跟不上,最后代码就是规格,规格就是代码。

这是 GitHub 想翻过来的。

Spec-Driven Development( SDD 的核心主张是:规格说明书变成可执行的,代码是规格的输出,而不是规格的指导。

翻译成人话:以前是"我想清楚,然后写代码";现在是"我想清楚,写规格,AI 从规格生成代码"。

这不是在改进写代码的方式,这是在重新定义谁来当老大

二、Spec Kit 是什么

GitHub 官方出的,听起来就很靠谱对吧? 实际上也确实靠谱——GitHub 的工程团队自己就在用这套方法论来开发产品,所以 Spec Kit 不是纸上谈兵,是从自己的真实工作里提炼出来的

定位:让你聚焦在"产品场景和可预期结果"上,而不是从零开始 vibe coding 每一个细节。

它提供一套结构化的工作流,让 AI 从"你说什么我做什么"变成"你说什么——我来确保做对"。

支持 Claude Code、Codex、Cursor 等主流 AI 编程工具。

三、六步工作流(核心)

整个工作流的设计思路很清晰:先定规矩 → 说清楚做什么 → 给技术方案 → 拆成小任务 → 执行 → 随时检查。每一步都有 slash command 对应,不用记复杂参数,AI 帮你串联全程。

1. Constitution —— 定规矩

/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements

这一步创建项目的治理原则:代码质量标准、测试要求、UX 一致性规范、性能指标。

这些原则会指导后续所有的开发工作,相当于给 AI 定下了"宪法"。

2. Specify —— 写规格

这是最重要的一步,也是 Spec Kit 和普通 AI 编程最大的区别。你只管说"做什么",技术细节完全不用操心。 你会发现——当你只说"做什么"的时候,AI 反而能更好地理解你的意图,不会自己脑补一堆"你可能想要"的功能。这大概就是传说中的少即是多吧。

/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.

这一步描述你要做什么。聚焦在"做什么"和"为什么",不要管技术栈。

你会发现——当你只说"做什么"的时候,AI 反而能更好地理解你的意图,不会自己脑补一堆"你可能想要"的功能。

3. Plan —— 给方案

/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.

规格确认后,这一步给出技术方案:选什么框架、用什么数据库、依赖怎么管理。

Plan 是规格和技术决策之间的桥梁——规格说"要一个照片应用",Plan 说"用 Vite + SQLite + 原生 JS 来实现"。

4. Tasks —— 拆任务

/speckit.tasks

AI 根据 Plan 自动拆解成可执行的任务清单。每个任务精确到文件路径、验收标准,完成一个打一个勾。

5. Implement —— 执行

/speckit.implement

AI 按照任务清单一个个执行。和 OpenSpec 一样,严格按照 Plan 来,不会自己跑偏

6. Review & Iterate —— 检查和迭代

实现过程中可以随时回到前面的步骤调整。规格改了,Plan 自动更新,Tasks 自动重新生成。

四、和 OpenSpec 的区别

这是大家最常问的问题。

OpenSpec Spec Kit
出品方 Fission-AI GitHub 官方
工作流 propose → apply → archive constitution → specify → plan → tasks → implement
风格 轻量、迭代友好 正式、有"宪法"概念
团队协作 支持 更好(支持 branch + merge 规格版本管理)
社区扩展 较少 丰富(Jira、Azure DevOps 等集成)

简单说:OpenSpec 更轻量,适合个人开发者快速上手;Spec Kit 更完整,适合团队协作和有正式流程要求的项目。

五、社区扩展生态(这是亮点)

这是我觉得 Spec Kit 最值得期待的地方——生态正在生长,而且很有意思。 截止目前社区已经贡献了十几种扩展,挑几个我,觉得特别有想法的说说:

集成类

  • Jira Integration:把 spec 规格自动同步到 Jira Epics 和 Stories
  • Azure DevOps Integration:同步到 Azure DevOps Work Items

代码质量类

  • Checkpoint Extension:实现中途提交,不至于最后只有一个巨大的 commit
  • Cleanup Extension:实现完成后自动 review 改动,修小问题,标记中等问题,生成大问题报告

流程类

  • Fleet Orchestrator:全生命周期编排,带 human-in-the-loop 关卡
  • Cognitive Squad:多智能体认知系统,理解→内化→应用

文档类

  • Archive Extension:合并后归档到项目记忆
  • DocGuard:Canonical-Driven Development 文档校验和评分

这些扩展开箱即用,按需安装。

六、怎么装

# 推荐:用 uv 持久安装(稳定版)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z

# 或者一次性运行(不需要安装)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>

⚠️ 前提:需要先安装 uv(一个极快的 Python 包管理器)。如果没装过,执行这一行就行:

curl -LsSf https://astral.sh/uv/install.sh | sh

装好后,在项目目录运行 specify init . --ai claude,AI 助手会自动识别并加载 Spec Kit。

七、技术原理

如果你对"它怎么做到的"感兴趣,这节简单说说。不感兴趣可以跳过,不影响你用它。

Spec Kit 的核心架构分四层,理解起来很简单:

  • Specify CLI:命令行工具,负责初始化项目、管理扩展、调度 AI Agent。是整个工具的入口。

  • Slash Commands/speckit.*):用户和 AI 交互的主要方式。每个 command 对应一个工作流阶段,AI 根据当前阶段决定下一步。

  • Spec Artifacts:规格说明书的输出格式。包含:

    • SPEC.md——产品需求文档
    • PLAN.md——技术实现方案
    • TASKS.md——任务清单
    • CONSTITUTION.md——项目治理原则
  • Extension System:社区扩展的插拔机制。扩展分为五类:

    • docs——读写/校验/生成规格文档
    • code——review/校验/修改代码
    • process——跨阶段工作流编排
    • integration——同步外部平台
    • visibility——项目健康度报告

八、和 Superpowers + ECC 的关系

这是 Claude skills 第四篇了,终于可以凑齐一桌麻将:

工具 出品 解决的问题
Spec Kit GitHub 官方 规格说明书 → 可执行,需求对齐
OpenSpec Fission-AI 轻量规格流程,迭代友好
Superpowers @obra 工程纪律,TDD + task 分解
ECC @affaan-m AI 性能和记忆,Token 优化

Spec Kit 和 OpenSpec 解决的是同一类问题(需求对齐),但 Spec Kit 更适合团队、更正式;OpenSpec 更轻量、更迭代。

Superpowers 在下游执行层——规格确认后,怎么按工程规范来做。

ECC 在底层——让 AI 跑得更稳、更快、更聪明。

四者组合,才是完整的 AI 编程工作流。


写在最后

用了 Spec Kit 之后,我最大的感受是: Spec Kit 把"我以为 AI 能读懂我"变成了"我明明白白告诉 AI 我要什么"。

这种感觉就像是——以前是让 AI 猜你的心思,现在是跟一个聪明但需要你说清楚的同事合作。累吗?稍微累一点,但返工少多了,信心也多了

Spec Kit 解决的就是这个问题——在 AI 动笔之前,先把"要什么"定义清楚。

GitHub 官方的背书也意味着它的长期维护有保障,扩展生态会更丰富。

如果你在团队里推动 AI 编程,用 GitHub 官方出品的工具,说服成本会低很多——不用解释这个工具靠不靠谱,直接说"这是 GitHub 官方做的"就够了。

当然,它也有学习成本。六个 command、constitution 怎么写、plan 怎么给,都需要摸索。但这个成本是一次性的,投入产出比很高。

你试过 Spec Kit 吗?或者你更偏向 OpenSpec 的轻量路线?欢迎评论区聊聊。

如果觉得有帮助,点个赞收藏一下,我会有更多动力继续写这个系列。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

昨天 — 2026年4月1日首页

本地执行 IPA 混淆 无需上传致云端且不修改工程的方案

2026年4月1日 18:12

在很多团队里,混淆这一步常常被外包给在线加固服务:上传 IPA,等结果,下载再签名。流程确实顺手,但当项目涉及商业逻辑或私有算法时,这种方式总让人有点不踏实——完整的二进制、资源、接口结构都离开了本地环境。

后来我们把这一步彻底改成本地执行,不上传任何文件不改工程源码只操作已编译好的 IPA


一、先确认 IPA 当前长什么样

把构建好的 IPA 复制一份并解压:

unzip app.ipa

进入目录:

Payload/App.app

检查三个位置:

1)二进制可读信息

strings AppBinary | head

如果能看到:

UserManager
PaymentService
VipController

说明符号没有做处理。


2)资源目录结构

assets/images/vip_banner.png
config/payment.json

路径本身已经带有业务语义。


3)前端资源

main.jsbundle
index.html

这些文件如果未压缩,直接可读。


二、本地链路的核心思路

整个流程不依赖任何远程服务,结构如下:

IPA 文件
→ 本地解析
→ 本地混淆
→ 本地资源处理
→ 本地签名
→ 本地测试

关键在于:所有操作都发生在开发机器上。


先处理 JS / H5(如果存在)

如果项目中包含 WebView 或 React Native 模块,可以在 IPA 处理前压缩脚本。

例如:

terser main.js -o main.min.js

或者:

uglifyjs page.js -o page.min.js

压缩后再替换回 IPA 资源目录。

这样可以先降低 JS 层的可读性。


在本地执行 IPA 符号混淆

这一步是核心。

使用 Ipa Guard 这类本地运行的 IPA 混淆工具,可以直接处理 Mach-O 文件,而不需要源码。

操作过程:

  • 打开工具
  • 导入 IPA
  • 进入「代码模块」

可以看到:

OC 类
Swift 类
OC 方法
Swift 方法

在列表中选择需要处理的符号,例如:

UserManager
PaymentHandler
VipService

执行后:

UserManager → k39sd2

整个过程在本地完成,不会上传任何数据。


资源文件本地重写

继续在 Ipa Guard 的资源模块中操作。

勾选:

  • 图片
  • JSON
  • HTML
  • JS

执行后:

vip_banner.png → a82kd.png
payment.json → x92ks.json

工具会自动更新引用路径。

这一层的作用是让资源结构失去语义。


改变资源指纹(避免“同源识别”)

如果多个应用使用相同资源,文件内容会成为识别依据。

在 Ipa Guard 中开启 MD5 修改:

md5 banner.png

处理前后不同。

文件视觉效果不变,但指纹已经改变。


清理调试信息

检查:

strings AppBinary | grep NSLog

如果存在日志或调试字符串,可以在混淆阶段删除。

Ipa Guard 提供调试信息清理选项。


补充一个“简单校验机制”

为了避免 IPA 被二次篡改,可以在原生层加入简单校验:

  • 计算关键文件 hash
  • 启动时验证

例如:

if hash != expected { exit(0) }

这一步不依赖混淆工具,但可以作为补充。


本地完成签名与安装

混淆后 IPA 已失去原签名,需要重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置证书。

连接设备后可以直接安装。


验证结果(这一步不能跳)

安装后重点检查:

  • 页面是否正常
  • 资源是否加载
  • 动态调用是否正常
  • WebView 内容是否可用

如果出现异常,通常是:

  • 某些符号被误混淆
  • 某些资源路径未正确更新

把 IPA 混淆完全放在本地执行,并不只是“更安全”的选择,它还带来一个实际好处:每一步都可控、可调试、可回滚。相比上传到云端处理,本地流程更适合需要长期维护的项目。

搭建一个云端Skills系统,随时随地记录TikTok爆款

作者 饼干哥哥
2026年4月1日 16:38

最近 Claude Skills 很火。

但我观察了一圈,发现大家都在陷入一种“开发者的自嗨”。

绝大多数 Skills 的应用场景都被死死锁在 IDE 里,锁在开发者的电脑前。

这叫开发提效,不叫业务提效。

真正的业务发生在移动端,发生在你通勤、吃饭、甚至躺在床上刷 TikTok 的时候。

如果你的 AI 能力必须打开电脑、输入命令行才能调用,那它的时空效率就是零。

于是我抛弃本地的 Claude Code,基于 OpenHands 做了一套云端 Skills 系统。

效果极其简单粗暴:

我在刷 TikTok,看到一个爆款视频,点击复制链接,敲击 iPhone 背面三下。

wxv_4355007050494509070

20 秒后,我的飞书多维表格里自动新增了一行数据。

Image

这行数据包含了:这个视频的无水印文件、Gemini 拆解的镜头语言分析、爆款原因推导,以及一套可直接复用的 AI 视频生成提示词。

全过程我不需要打开电脑,不需要切换 APP,不需要等待。

这就是我今天要聊的:如何用 OpenHands + Skills + iOS 快捷指令,构建一套真正落地的业务自动化系统。

01 为什么 Claude Code 在业务侧是伪需求

先厘清两个概念:OpenHands 和 Claude Code。

Claude Code 是 Anthropic 官方推出的命令行工具,它是一个嵌入在你本地终端里的结对程序员。它的 Skills 本质是上下文记忆和本地工具接口。

它的优势是懂你的代码规范,能直接改你电脑里的文件。

但它有一个对于业务场景的致命弱点:它必须依附于你的会话,你不在,它就不动。

它是一个副驾驶(Copilot)。

而 OpenHands(前身 OpenDevin)是一个开源的、自主的 AI 软件工程师。它运行在 Docker 容器里,是一个独立的服务端 Agent。

Image

openhands.dev/

它是一个可以被封装成 API 服务的数字员工。

我看重 OpenHands 的核心理由只有一个:它可以 24 小时在线,并且可以通过 API 远程唤醒。

我做的这个 TikTok 分析系统,本质就是把 OpenHands 部署在服务器上,通过 FastAPI 暴露接口。

Claude Code 是给你用的工具;OpenHands 是你雇佣的、随时待命的员工。

🐵

小提示:FastAPI 的服务地址后加/docs就是文档了

02 业务视角:从 刷视频 到「数据入库」的闭环

对于做出海营销和短视频矩阵的朋友,拆解爆款是每天的必修课。

传统的流程极其反人类:

  1. 1. 手机刷到视频,点收藏。
  2. 2. 晚上回家打开电脑,把链接导出来。
  3. 3. 找第三方工具去水印下载。
  4. 4. 把视频传给 Gemini 分析。
  5. 5. 人工把分析结果复制粘贴到 Excel 或飞书。

这个链路太长,断点太多。任何需要延迟满足的流程,最终都会变成不了了之。

我的远程 Skills 方案,把这个流程压缩到了极致。

整个逻辑是这样的:

Image

用户端(前端)

利用 iOS 自带的快捷指令 + 背部轻点功能。

  • 动作:获取剪贴板内容(TikTok 链接)。
  • 触发:发送 HTTP POST 请求给我的服务器。
  • 反馈:手机震动一下,表示任务已接收。

Image

Image

服务端(后端)

OpenHands 接收到请求后,自主执行以下 Skills:

  1. Playwright Skill:

启动无头浏览器。这里有一个技术难点,TikTok 的反爬虫机制非常严格。如果用普通的 request 请求,成功率几乎为零。OpenHands 调用 Playwright 模拟真实浏览器行为,绕过 blob 协议,抓取真实的 MP4 视频流。这种方式的下载成功率稳定在 70%-80%

  1. Gemini Skill:

视频下载后,调用Gemini 2.5 Flash,快且便宜。它不只是看,它是理解。它可以识别拍摄角度(俯拍/特写)、运镜方式(推拉摇移)、BGM 节奏点、色彩心理学。

  1. Feishu Skill:

将清洗好的结构化数据(JSON),通过 API 写入飞书多维表格。

结果:

当你刷完半小时视频,打开飞书,几十个爆款视频的深度分析报告已经整整齐齐躺在那里了。

这才是 AI 赋能业务的本质:隐形化。

Image

Openhands 的 Skills 文档:

docs.openhands.dev/sdk/guides/…

03 举一反三:跨境电商的远程 Skills 玩法

这套架构的核心逻辑是:移动端触发 -> 服务端 API -> OpenHands 执行复杂 Skills -> 结果回传。

这个逻辑在出海业务里有无限的延展性。

我给几个具体的场景,你们可以拿去直接落地。

场景一:竞品独立站监控

  • 动作:在手机浏览器看到竞品的 Shopify 店铺,复制链接,触发 Shortcut。
  • Skills:OpenHands 调起爬虫 Skill 扫描该站点的新品上架情况、价格策略,并调用 SEO Skill 分析其关键词布局。
  • 产出:一份竞品分析简报直接推送到你的 Slack 或 钉钉。

场景二:亚马逊差评自动预警与回复草稿

  • 动作:系统监控到差评(自动触发,无需人工)。
  • Skills:OpenHands 读取差评内容,结合历史客服知识库 Skill,分析用户情绪,并模仿金牌客服的语气撰写 3 个版本的回复邮件。
  • 产出:草稿进入审核流,你只需要在手机上点批准。

场景三:广告素材批量生产

  • 动作:上传一张产品图到指定文件夹。
  • Skills:OpenHands 识别产品特征,调用 Midjourney 或 Runway 的 API,结合当下的流行趋势 Skill,自动生成 10 种不同风格的广告背景图。
  • 产出:素材自动同步到 Google Drive 供投放团队筛选。

04 为什么非要用 Agent Skills?写个 Python 脚本不行吗?

这是很多技术出身的朋友最容易陷入的误区。

你这个功能,我写个 Python 脚本 + 定时任务也能跑,为什么要搞这么复杂的 OpenHands Skills?

因为业务逻辑是流动的,而脚本是僵死的。

如果你写死了一个 Python 脚本:

  • 当 TikTok 的前端代码更新了 class 名,脚本报错,你得去修。
  • 当飞书的 API 接口变动,脚本报错,你得去修。
  • 当 Gemini 的模型参数调整,脚本报错,你得去修。

但在 OpenHands Skills 的架构下,我们定义的不是步骤,而是目标。

在我的 Skill 定义里,我告诉 OpenHands:你的任务是下载这个页面上的视频,如果常规方法失败,尝试模拟用户滚动;如果还失败,检查是否有验证码并尝试通过。

OpenHands 作为一个 Agent,它具备自主决策和自我修复的能力。

  • 它发现 TikTok 改了页面结构?它会尝试用视觉识别去定位播放按钮。
  • 它发现 API 报错?它会自主查阅文档或尝试备用节点。

在跨境出海这种平台规则朝令夕改的环境下,维护脚本的成本极高。

我们需要的是一个能够理解意图并自主寻找路径的智能体。

05 思路打开,Agentic Skills 的高级玩法

文章到这里,这套远程 Skills 系统的雏形已经搭建完毕。

但如果你觉得这就结束了,那你就小看了 Agentic Skills 的天花板。

我们现在的架构是“一个请求触发一个 Skill”,但这只是冰山一角。真正的威力在于 Multi-Skill Orchestration(多技能编排)。

  1. 1. Skill Chain(技能链)与递归调用

OpenHands 的 Skill 本质是可执行的逻辑单元。我们可以像写代码一样,让 Skill A 去调用 Skill B。

  • 比如定义一个 Base-Skill:只负责做基础的数据清洗。
  • 再定义一个 Pro-Skill:先调用 Base-Skill 处理数据,再把结果传给 Analysis-Skill,最后调用 Report-Skill 生成报告。

你可以构建一个自我迭代的 Agent。让它先写一段代码(Coding Skill),然后自己运行测试(Testing Skill),如果报错,递归调用 Coding Skill 进行修复,直到测试通过。

  1. 混合云架构(Hybrid Agent Architecture)

OpenHands 运行在 Docker 里,这意味着它可以部署在任何地方。

  • 私有化部署:对于涉及公司财务、用户隐私的数据,你可以把 OpenHands 部署在公司内网服务器上。
  • 公有云调用:对于需要访问外网(如 TikTok 下载、竞品分析)的任务,部署在 AWS 或 Vercel 上。

这样,通过 API 网关,你可以指挥内网的 Agent 去调用外网的 Agent,实现数据在安全域和互联网域之间的智能流转。

  1. “人机回环”的异步交互

谁说 API 只有“请求-响应”这一种模式? 在我的系统中,有些复杂任务(如竞品深度调研)可能需要运行 30 分钟。

  • 流程设计:OpenHands 接收任务 -> 立即返回 TaskID -> 后台异步执行。
  • 关键点:当 Agent 遇到无法决策的卡点(例如:这个验证码我解不开,或者这个竞品网站有两套价格体系,取哪套?),它可以主动通过飞书/Slack 给你发消息请求确认。

你点击确认后,Agent 继续执行。这才是真正的人机协作:AI 处理海量冗余信息,人类只在关键节点做决策。

在这个体系下,Skills 不再是静态的脚本,而是可生长、可组合的原子能力。

未来,你的个人服务器里可能运行着上百个这样的 Skills。它们是一群田螺姑娘,在你睡觉的时候,帮你监控市场、回复邮件、整理知识、优化代码。

而你,只需要握着手机,轻轻敲两下背部,就像魔法师挥动了魔杖。

这,才是 Agent 时代的真正玩法。

OpenSpec:让 AI 编程从"开盲盒"到"先签字再干活"

作者 清汤饺子
2026年4月1日 09:14

Hi~大家好呀,我是清汤饺子。

我曾经让 AI 改个按钮颜色,它噼里啪啦一顿操作,把整个配色系统重构了。

你问我什么配色系统?我没说要重构配色系统啊。

AI:但重构之后更好看啊。

……行吧。

这个场景你经历过吗?需求说加个功能,AI 直接把项目翻了个底朝天。最后你花的时间比不用 AI 还多三倍。

问题出在哪?不是 AI 不够聪明,是你和 AI 之间没有"签字画押"

然后我发现了 OpenSpec。

一、解决什么问题

AI 编程最大的噩梦,不是它写得慢,是它完全按自己的理解来

你脑子里想的是 A,AI 理解的可能是 B,它输出的变成了 C。你还得花时间把 C 改回接近 A 的样子。

这就是没有"需求对齐"的问题。

传统的软件开发里,有个东西叫Spec(需求规格说明书) ——在动手之前,产品、设计、开发三方签字确认"我们要做什么"。

OpenSpec 把这个机制引入了 AI 编程:先签字,再干活

不是让 AI 写文档,是让 AI 和你在"做什么"这件事上真正对齐。

二、OpenSpec 是什么

作者是 Fission-AI,GitHub 上叫 OpenSpec

定位是:The most loved spec framework

核心价值主张:在写代码之前,AI 和人类对"要做什么"达成一致。

它解决的是 Superpowers 和 ECC 没覆盖到的那个环节——Superpowers 管"工程纪律",ECC 管"性能和记忆",OpenSpec 管**"需求对齐"**。

三个工具解决的问题正好互补。

三、核心理念

OpenSpec 的 Philosophy 里有几条我特别认同:

fluid not rigid — 流畅不僵硬

它不是要你写几十页的瀑布式文档。Spec 应该像对话一样自然,迭代式的。

iterative not waterfall — 迭代而非瀑布

以前的需求文档是一次性写完,然后"锁死"。OpenSpec 的 spec 是可以迭代的,每次改动都有记录。

easy not complex — 简单不复杂

不要搞得太重,简单到你可以随时改。

built for brownfield not just greenfield — 支持存量项目

不只是新项目可以用,存量项目也能用。真实开发场景大多是在别人写的代码上改,不是从零新建。

scalable from personal to enterprise — 从个人到企业级

一个人能用,团队也能用。

四、核心工作流

OpenSpec 的工作流就三步,非常简单:

1. propose —— 提案

你告诉 AI:/opsx:propose add-dark-mode

AI 自动生成一整套文档:

  • proposal.md — 为什么要做这个改动
  • specs/ — 具体需求和边界情况
  • design.md — 技术方案
  • tasks.md — 任务清单,每个任务精确到文件路径

这是最让我惊喜的地方——以前要人类自己写需求文档,现在 AI 帮你写。你只需要确认、修改,不需要从零憋字。

2. apply —— 执行

你确认 spec 之后:/opsx:apply

AI 按照 tasks.md 清单一个个执行。每完成一个任务打一个勾,你可以随时喊停。

最关键的是:AI 严格按照 spec 来执行,不会自己跑偏

你改个按钮颜色,它就只改按钮颜色,不会顺手把配色系统重构了。

3. archive —— 归档

收尾:/opsx:archive

归档到 openspec/changes/archive/,保持项目干净,下次新需求不影响旧的。

五、Artifact-Guided Workflow(新功能)

这是 v2 的核心升级。

以前是你手工写 spec,AI 按 spec 执行。现在是 AI 根据你的 idea 自动生成完整 artifact

你只管说想做什么,AI 把 proposal、specs、design、tasks 全套给你生成出来。你审核、修改、确认,然后 apply。

人类只需要在关键节点做决策,不需要从头参与到每一个细节。

六、我的真实感受

惊喜时刻

  • 终于有了一个"需求对齐"的机制。AI 不会自己跑偏,因为它要按 spec 执行
  • propose 之后 AI 自动生成 spec,质量居然还不错
  • 有了 spec 做依据,code review 也轻松多了

崩溃时刻

  • spec 写错了,apply 之后全错了。AI 是严格按照 spec 执行的,spec 对它就是对,spec 错它就错
  • 需要花时间养成写 spec 的习惯,一开始会觉得"这么麻烦干嘛"

适合谁

  • 有一定复杂度的需求,不是"改个 typo"这种
  • 团队协作场景,需要对齐多方预期
  • 讨厌 AI 自己跑偏的人

不适合谁

  • 简单任务,直接让 AI 写反而更快
  • 纯探索性开发,还没想清楚要做什么

七、和 Superpowers + ECC 的关系

这三个工具解决的问题正好互补:

工具 解决的问题
OpenSpec 需求对齐 — 先签字再动手
Superpowers 工程纪律 — TDD、task 分解、子 Agent 编排
ECC 性能和记忆 — Token 优化、memory 持久化、安全扫描

OpenSpec 在最上游——它管的是"做什么"。

Superpowers 在中游——它管的是"怎么做"。

ECC 在底层——它管的是"怎么跑得更好"。

三个一起用,才是完整的 AI 编程工作流。

八、技术原理

看完 GitHub 仓库,我发现 OpenSpec 的核心设计非常简洁——它不是给 AI 增加能力,而是给 AI 和人类之间增加一个"共同文档"作为契约

1. 核心机制:Artifact + 人类确认

OpenSpec 的工作流核心是 Artifact 生成 + 人类确认

  1. 提出一个 high-level 的想法("我想加个深色模式")
  2. AI 根据你的想法,自动生成一整套 Artifact(proposal.md、specs、design.md、tasks.md)
  3. 审核、修改、确认这整套文档
  4. AI 严格按照确认后的 Artifact 执行

关键是:在第三步你签字确认之前,AI 不会动手写代码

2. OpenSpec Directory 结构

OpenSpec 在项目根目录创建的结构非常清晰:

openspec/
├── changes/
│   └── archive/         # 每次改动的归档
├── specs/               # 需求规格
├── design/              # 技术方案
├── tasks/               # 任务清单
└── .openspecrc          # 配置文件

这个结构让每一次改动都有据可查、可回滚。

3. propose 的内部逻辑

当 AI 执行 /opsx:propose 时,内部经历:

  1. 理解需求:AI 解析你的自然语言输入
  2. 生成 proposal.md:回答"为什么要做这个改动"
  3. 生成 specs/ :拆解需求成具体规格和边界情况
  4. 生成 design.md:给出技术方案和备选方案
  5. 生成 tasks.md:拆解为可执行的任务清单,每个任务精确到文件路径

这个过程本质上是 Socratic 式的 逆向工程——AI 不是在执行,而是在问你"你要做什么、做到什么程度"。

4. 人类在环(Human-in-the-Loop)

OpenSpec 每一层都有"人类确认"的节点:

想法 → proposal → 你确认 → specs → 你确认 → design → 你确认 → tasks → apply

这不是审批流程,这是 协作编辑。你在确认的过程中可以修改、补充、否定。AI 的 proposal 不是终点,是起点。

5. v2 Artifact-Guided Workflow 的升级

v2 最大的变化是:AI 不再只是执行者,还是提案者

以前是"你写 spec,AI 执行"。现在变成"你说想法,AI 帮你写 spec,你确认,AI 执行"。

从"你主导"变成了"AI 主导生成,你主导确认"——这降低了使用门槛,让不擅长写 spec 的人也能用上这套方法论。

GitHub 仓库:github.com/Fission-AI/…

写在最后

OpenSpec 解决了一个根本问题:AI 编程最大的风险不是 AI 不够聪明,是人类和 AI 对"做什么"没有对齐

你花 10 分钟写清楚 spec,AI 按 spec 执行,省掉的可能是一小时的返工。

当然,它不是万能的。spec 写对了才有效,写错了反而会误导 AI。

核心问题是:你愿不愿意在动手之前,先花时间想清楚"到底要做什么"?

这件事,AI 帮不了你。


你在 AI 编程时有没有过"AI 完全按自己理解来"的经历?最后是怎么解决的?欢迎评论区聊聊。

如果觉得有帮助,点个赞收藏一下,我会更有动力更新下一期。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

Claude Code 是怎么跑起来的:从 Agent Loop 理解代理循环实现

作者 candyTong
2026年4月1日 00:15

如果你已经会调用大模型、也知道 tool calling 和 agent 的基本概念,那接下来最值得看的问题通常不是“怎么再包一层 prompt”,而是:一个真正能跑任务的 agent,到底是怎么在代码里运转起来的。

这篇文章不从抽象定义讲起,而是直接从 Claude Code 的实现思路切入,拆解它最核心的一条执行主线:Agent Loop

1. 什么是 Agent Loop

一句话:Agent Loop 是让模型从“一次回答”升级为“持续决策系统”的控制循环。

你可以把它理解成一个回合制 runtime,每一回合都做同样的事情:

  1. 读取当前上下文;
  2. 调用模型生成动作意图;
  3. 如果有工具调用就执行工具;
  4. 把工具结果写回上下文;
  5. 判断是否进入下一回合。

这个循环停止时,系统才真正产出“任务完成态”的结果,而不是中间状态。

2. 最小可用模型:先跑通闭环

先看一个最小版本:

let messages = [userMessage];

while (true) {
  const assistant = await callModel(messages);
  messages.push(assistant);

  const toolUses = extractToolUseBlocks(assistant);
  if (toolUses.length === 0) {
    break;
  }

  const toolResults = await runTools(toolUses);
  messages.push(...toolResults);
}

return buildFinalAnswer(messages);

这段代码虽然简单,但已经包含了 agent 的本质:

  • 状态是累积的:不是每次从零思考;
  • 决策是迭代的:不是一轮生成定生死;
  • 外部世界可进入推理链:工具结果会回流给模型。

如果你只能记住一个原则,就是这个:
工具结果不是“展示给用户就完了”,而是“下一轮推理输入”。

3. 真实实现的主流程

在工程实现里,Agent Loop 一般会拆成“外层编排 + 内层循环 + 模型流式适配”三层:

flowchart TD
    A["Orchestrator(会话编排)"] --> B["Agent Loop(回合循环)"]
    B --> C["Model Stream(流式消息)"]
    C --> D{"是否出现 tool_use?"}
    D -- "是" --> E["执行工具并产出 tool_result"]
    E --> B
    D -- "否" --> F["结束循环并输出结果"]

这种分层有个明显好处:
你可以在不改核心循环逻辑的前提下,替换模型供应商、切换工具执行策略,或者接入远端会话。

这句话可以拆开理解。

核心循环真正关心的,其实只有几件事:

  1. 当前消息上下文是什么;
  2. 这一轮模型产出了什么事件;
  3. 有没有 tool_use
  4. 工具结果什么时候回流;
  5. 当前任务是否应该继续下一轮。

也就是说,Agent Loop 关心的是“控制流程”,而不是某个具体实现细节。

比如:

  • 如果你把底层模型从 Anthropic 换成 OpenAI,只要新的模型适配层仍然能把输出整理成统一的消息事件流,循环本身就不需要重写。
  • 如果你把工具执行从“串行执行”换成“并发执行”或“流式执行”,只要工具结果最终还是按统一格式回到消息链,循环判断逻辑也不用变。
  • 如果你把运行方式从“本地单进程”换成“远端 agent 会话”,只要远端返回的消息还能被还原成同一套 assistant、tool result、status 事件,循环依然可以照常推进。

所以这层设计的关键价值是“隔离变化”:

  • 上层编排器负责会话和运行环境;
  • 中间的 Agent Loop 负责任务推进;
  • 下层适配器负责模型协议、工具协议、远端通信。

这样一来,变化最多的部分被压到了外围,最核心的循环本身反而能保持稳定。

4. 一个回合是怎么结束的:Claude Code 如何做健壮收口

这一节其实只想说明一个核心点:在 Claude Code 里,“这一轮没有新的工具调用了”并不等于“立刻结束”。

真正的逻辑是:

  1. 先看这一轮是否还需要 follow-up;
  2. 如果不需要,也不会马上返回;
  3. 系统会先尝试几条恢复路径;
  4. 只有恢复都失败了,才真正结束这一轮。

先看续轮判断本身。在 query.ts 里,Claude Code 还是会先根据消息内容里的真实 tool_use 来设置 needsFollowUp

// query.ts
const toolUseBlocks: ToolUseBlock[] = [];
let needsFollowUp = false;

const msgToolUseBlocks = message.message.content.filter(
  content => content.type === 'tool_use',
) as ToolUseBlock[];

if (msgToolUseBlocks.length > 0) {
  toolUseBlocks.push(...msgToolUseBlocks);
  needsFollowUp = true;
}

但关键在后面。Claude Code 的健壮性,不在于它会判断 needsFollowUp,而在于它在 !needsFollowUp 时不会立刻退出,而是先检查“这次能不能修一修再继续跑”。

把这一段抽象后,可以写成下面这样:

// src/query.ts - 简化后的退出/恢复逻辑
if (!needsFollowUp) {
  const lastMessage = assistantMessages.at(-1);

  // 恢复路径 1: Prompt 太长 -> 尝试上下文折叠
  if (isPromptTooLongMessage(lastMessage)) {
    const drained = contextCollapse.recoverFromOverflow(messages);
    if (drained.committed > 0) {
      state.messages = drained.messages;
      continue;
    }

    // 恢复路径 2: 响应式压缩
    if (!state.hasAttemptedReactiveCompact) {
      const compacted = reactiveCompact(messages);
      state.messages = compacted;
      state.hasAttemptedReactiveCompact = true;
      continue;
    }
  }

  // 恢复路径 3: max_tokens 截断 -> 增加 token 预算
  if (lastStopReason === 'max_tokens') {
    state.maxOutputTokensOverride = currentLimit * 2;
    state.maxOutputTokensRecoveryCount++;
    continue;
  }

  // 真正退出
  return { reason: 'end_turn' };
}

这段逻辑最值得注意的地方是连续的三个 continue

它说明 Claude Code 对“结束”这件事的态度不是保守退出,而是优先恢复:

  • 如果是 prompt 太长,先尝试上下文折叠;
  • 折叠不够,再尝试响应式压缩;
  • 如果是输出被 token 上限截断,再提高 token 预算重试;
  • 只有这些恢复路径都走不通,才真正返回 end_turn

所以这一节真正想表达的就是一句话:

在 Claude Code 里,一个回合结束不是“没工具了就退出”,而是“没工具了之后,先把能恢复的情况恢复掉,恢复不了才结束”。

这就是它和普通 demo 的差别。demo 往往只会判断“继续还是退出”,而 Claude Code 还多做了一层“退出前恢复”,因此在长上下文、输出截断这类真实问题面前,循环不会轻易中断。

5. queryLoop 才是真正的发动机,它的流式输出是“按消息块”推进的

如果说 Agent Loop 的概念骨架是 while (true),那真正让 Claude Code 跑起来的,是 queryLoop 这种“边接收、边判断、边产出”的实现方式。

这里有一个很值得讲清楚的点:Claude Code 的流式,不是很多人想象中的“逐字打印”。它更接近一种“按消息块、按事件块”的流式。

也就是说,系统不是每生成一个字就立刻往外吐一次,而是随着模型流中的事件推进,不断 yield message

这个差别很重要,因为它直接影响你怎么理解 queryLoop

5.1 为什么 yield message 就等于流式输出

理解 Claude Code 的流式,最好的方式不是只看一层代码,而是看它怎么“一层一层往外 yield”。

先看里面这一层,也就是 queryLoop。它一边跑 Agent Loop,一边把每轮里从模型拿到的消息往上抛:

// src/query.ts
async function* queryLoop(
  params: QueryParams,
): AsyncGenerator<StreamEvent | Message, Terminal> {
  while (true) {
    for await (const message of queryModel(
      state.messages,
      systemPrompt,
      tools,
      signal,
    )) {
      yield message;
    }

    // ---- 步骤 2: 检查是否需要继续 ----
    if (!needsFollowUp) {
      return { reason: 'end_turn' };
    }

    // ---- 步骤 3: 执行工具,收集结果 ----
    // ---- 步骤 4: 追加工具结果到消息列表 ----
    // ---- 步骤 5: 回到步骤 1 ----
    state.turnCount++;
  }
}

这说明第一层流式非常直接:queryModel() 持续产生消息,queryLoop() 就持续 yield message。所以对 queryLoop 来说,流式的本质就是“这轮里有什么消息到达,我就继续往上一层送什么消息”。

再看外面这一层,也就是 QueryEngine。它接收用户输入,把消息放进跨轮次保存的 mutableMessages,然后继续消费内部的 query(),再把内部消息流转成对外消息流:

// src/QueryEngine.ts
export class QueryEngine {
  // 跨轮次持久化的消息列表 —— 这就是教学版的 messages[]
  private mutableMessages: Message[]
  private abortController: AbortController
  private totalUsage: NonNullableUsage

  // 入口:用户输入进来,响应消息流出去
  async *submitMessage(
    prompt: string | ContentBlockParam[],
    options?: { uuid?: string; isMeta?: boolean },
  ): AsyncGenerator<SDKMessage, void, unknown> {
    // 1. 处理用户输入
    const { messages: messagesFromUserInput } = await processUserInput({...})
    this.mutableMessages.push(...messagesFromUserInput)

    // 2. 调用核心查询循环
    for await (const message of query({
      messages: [...this.mutableMessages],
      tools: this.tools,
      systemPrompt: this.systemPrompt,
      ...
    })) {
      // 3. 累积消息并持久化
      if (message.type === 'assistant') {
        this.mutableMessages.push(message)
        yield* normalizeMessage(message)
      }
    }
  }
}

这样一来,整条链路就清楚了:

  1. queryModel() 先在最底层按事件产生消息;
  2. queryLoop() 在每一轮里把这些消息 yield 出来;
  3. QueryEngine.submitMessage() 再把内部消息流继续 yield 给外部。

所以 Claude Code 的“流式”本质上不是某一层单独在流,而是异步生成器在层层传递消息。

可以用一张图先把这条链路记住:

flowchart TD
    A["queryModel<br/>底层模型事件流"] --> B["queryLoop<br/>按轮次处理并 yield message"]
    B --> C["QueryEngine.submitMessage<br/>消费内部消息流并继续 yield"]
    C --> D["UI / SDK<br/>接收对外消息流"]

很多人以为“流式”就一定是 token 级别的逐字显示,但 Claude Code 这类 runtime 通常不会直接把最底层 token 流暴露给最外层逻辑。

原因是 runtime 真正要处理的,不只是文本,还有很多结构化信息:

  • assistant message
  • stream event
  • tool use
  • tool result
  • progress
  • status
  • result

这些东西本身就不是“一个字一个字”的概念,而是分段、分块、分事件到达的。

所以更准确地说,Claude Code 的流式是:

  • LLM 底层协议是更细粒度的事件流;
  • 中间层把这些事件整理成可消费的消息块;
  • queryLoop 再把这些消息块持续 yield 给外层。

这就是为什么你会感觉它是“一段一段出来”,而不是终端里那种纯文本逐字打印。

6. QueryEngine 是外层编排器,它不负责思考,但负责把循环组织起来

理解完 queryLoop 之后,再看 QueryEngine 会更清楚。

queryLoop 是发动机,但发动机本身并不负责整辆车的所有事情。Claude Code 还需要有一个更外层的组件,把会话、消息记录、结果聚合、状态同步这些事情组织起来,这就是 QueryEngine 这类角色存在的意义。

你可以把它理解成“外层编排器”。

它通常不直接决定模型这一轮该不该调工具,也不直接决定工具参数是什么。那些都是 loop 内部和模型共同完成的事情。QueryEngine 更关心的是:

  • 这次查询从哪里开始;
  • 初始消息怎么组装;
  • 流式过程中哪些消息要记录;
  • 哪些中间消息要回放给 UI 或 SDK;
  • 本轮结束后,最终结果应该如何包装返回;
  • 会话级别的 usage、cost、turn count 如何累计。

这件事在源码里也非常直观。QueryEngine 一开始维护的就是一批会话级状态:

// QueryEngine.ts
export class QueryEngine {
  private mutableMessages: Message[];
  private abortController: AbortController;
  private permissionDenials: SDKPermissionDenial[];
  private totalUsage: NonNullableUsage;
  private readFileState: FileStateCache;
}

进入一次查询之后,它会继续跟踪这次会话的关键统计:

// QueryEngine.ts
let currentMessageUsage: NonNullableUsage = EMPTY_USAGE
let turnCount = 1
let lastStopReason: string | null = null

for await (const message of query({ ... })) {
  // 消费 query() 产出的消息,并累计 usage / turn / stop_reason
}

最后再把这些状态收束成对外返回的 result:

// QueryEngine.ts
yield {
  type: 'result',
  subtype: 'error_max_budget_usd',
  duration_ms: Date.now() - startTime,
  num_turns: turnCount,
  stop_reason: lastStopReason,
  total_cost_usd: getTotalCost(),
  usage: this.totalUsage,
}

这里虽然展示的是一个具体错误分支,但它已经足够说明 QueryEngine 的职责:
它负责把 loop 过程中分散产生的状态,最后整理成一个完整、统一、可返回的结果对象。

也就是说,QueryEngine 解决的是“这一整次交互怎么被托管”,而 queryLoop 解决的是“这一轮又一轮怎么往前跑”。

6.1 为什么要有这一层

如果没有 QueryEngine 这一层,很多原本应该属于“会话级”的工作,就会被硬塞进 loop 里,比如:

  • transcript 持久化;
  • 历史消息回放;
  • result 聚合;
  • session 级别统计;
  • UI/SDK 的输出适配。

这样一来,queryLoop 会越来越胖,最后既要管控制流,又要管存储,又要管展示,维护成本会非常高。

QueryEngine 单独拉出来之后,职责边界就清楚了:

  • QueryEngine 负责托管整次查询;
  • queryLoop 负责推进 agent 回合;
  • 更底层的 model adapter 负责协议流;
  • 工具执行器负责工具生命周期。

这正是前面提到的“隔离变化”的具体体现。

7. 结语

如果只用一句话总结这篇文章,我会说:Claude Code 的 Agent Loop,本质上是一套围绕“消息流”组织起来的运行时。

前面几节其实都在说明这件事,只是从不同层次切进去:

  • 在第 4 节里,我们看到一个回合并不是“没工具了就退出”,而是会先尝试恢复,恢复不了才真正结束;
  • 在第 5 节里,我们看到所谓流式输出,并不是单层函数在打印文本,而是 queryLoopQueryEngine 两层异步生成器在层层 yield
  • 在第 6 节里,我们又看到 QueryEngine 如何把这些分散的中间状态组织成一整次可托管、可返回的查询过程。

把这些点连起来看,Claude Code 其实回答了一个很实际的问题:

一个 agent 为什么能持续跑下去,而且还能在复杂情况下不轻易跑崩?

答案不是“它 prompt 写得更好”,而是它把消息、回合、恢复和对外输出组织成了一套清晰的 runtime 结构。

所以读完这篇文章之后,最值得带走的并不是某个具体函数名,而是这条主线:

  1. queryLoop 负责把一轮一轮的 agent 行为跑起来;
  2. QueryEngine 负责把这套循环托管成一次完整交互;
  3. 整个系统通过消息流把模型输出、工具结果和最终结果串成同一条链路。

当你理解了这一点,再回头看 Claude Code,就会发现它真正厉害的地方,不是“会调用工具”,而是它把 agent 的执行过程做成了一个稳定、连续、可恢复的运行时。

昨天以前首页

Claude Code 源码中 REPL.tsx 深度解析:一个 5005 行 React 组件的架构启示

作者 HashTang
2026年3月31日 23:06

Claude Code 的源码泄漏之后,发现它的核心交互界面 src/screens/REPL.tsx 居然有 5005 行。一个文件。一个函数组件。

好奇心驱动我通读了一遍。约 290 个 import,60+ 个 useState,30+ 个 useEffect,20+ 个 useCallback。这个组件跑在 Ink(React 的终端渲染器)上面,承载了 Claude Code CLI 几乎所有的交互逻辑。

读完之后感触很复杂——有些地方写得确实漂亮,有些地方你能感觉到是被 deadline 推着走的妥协。记录一下。


这个文件干什么用的

REPL 就是 Read-Eval-Print Loop。打开终端敲 claude,你看到的整个界面就是这个组件在渲染。它负责:

  • 接收你的输入(文字、斜杠命令、粘贴的图片、语音)
  • 跟 Claude API 通信(流式响应、工具调用、中断)
  • 画出终端界面(消息列表、等待动画、权限弹窗、搜索)
  • 协调多种运行模式(本地、远程 WebSocket、SSH、Direct Connect、Swarm 多 agent 协作)
  • 管理会话(创建、恢复、fork、丢到后台、退出)

技术栈是 React 19 + React Compiler + Ink + TypeScript,构建工具是 Bun。


写得漂亮的地方

编译期条件导入

const useVoiceIntegration = feature('VOICE_MODE')
  ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration
  : () => ({ stripTrailing: () => 0, handleKeyEvent: () => {}, resetAnchor: () => {} });

feature() 是 Bun 的编译期常量。构建的时候,没开的功能连 require 那一行都会被消除掉,包括它引入的整个模块依赖树。

妙在 stub 的设计。给了个返回空操作的函数,而不是 null。这样后面 useVoiceIntegration() 该调用照调用,不用到处写 if (feature('VOICE_MODE')) 守卫,Hook 调用顺序也不会乱。用 typeof import(...) 约束 stub 签名和真实实现一致,类型层面就堵住了不匹配的口。

整个文件有十几处这种模式,涵盖语音输入、挫折检测、组织告警、Coordinator 模式等内部功能。外部发布版本的产物里,这些代码物理上就不存在。比运行时 flag 判断干净太多了。

QueryGuard 并发状态机

const queryGuard = React.useRef(new QueryGuard()).current;
const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot);

大部分 React 应用处理"是否在加载"就是一个 useState(false)。但 Claude Code 面对的场景比普通应用复杂——用户可以快速按 Enter 提交、Esc 取消、再按 Enter 重新提交,中间还可能有后台 agent 的通知触发新查询。

传统的 useState + useRef 双写模式在这种场景下很容易翻车,因为 React 的 setState 是异步批处理的,ref 和 state 之间会出现时间窗口不一致。

QueryGuard 把这个问题建模成了一个状态机,四个原子操作(reserve / tryStart / end / forceEnd),加一个 generation 计数器。当用户按 Esc 取消再立即重新提交时,旧查询的 finally block 里拿到的 generation 跟当前不匹配,就知道自己已经过时了,不会去清理新查询的状态。

通过 useSyncExternalStore 暴露给 React,不需要手动 setState,订阅者自动感知变化。这是正确处理这类问题的方式,但说实话在业界能看到这种做法的项目不多。

同步 Ref 镜像——"Zustand 模式"

const setMessages = useCallback((action) => {
  const prev = messagesRef.current;
  const next = typeof action === 'function' ? action(messagesRef.current) : action;
  messagesRef.current = next;  // 同步写 ref
  rawSetMessages(next);         // 异步通知 React
}, []);

React 的 setState 是异步的,但很多回调需要同步读到最新值。常规做法是 useEffect 里同步 ref,但会有一帧延迟。

Claude Code 直接在 setState 的包装器里先写 ref,再把算好的结果(注意不是 updater 函数)传给真正的 rawSetMessages。代码注释里管这叫"Zustand 模式"——ref 是 source of truth,React state 是它的渲染投影。

这个模式在文件里被反复使用:messagesRefinputValueRefstreamModeRefabortControllerReffocusedInputDialogRef... 大概有七八处。如果你的 React 应用也有"异步回调里读状态总是旧的"这个痛点,这是目前最实用的解法。

细致的性能管理

这个文件里的性能优化不是那种"加个 memo 完事"的程度,而是对 React 渲染模型有系统性理解后做的:

动画隔离:终端标题有个 960ms 一跳的动画前缀( / 交替)。如果把 setInterval 放在 REPL 主组件里,每秒就多一次整棵树的 re-render。所以他们提取了一个 AnimatedTerminalTitle 组件,返回 null(纯副作用),tick 只触发这个空组件的 re-render。

Ref 替代频繁变化的 StatestreamMode 在流式响应期间大概切换 10 次(requesting → responding → tool-use 循环)。如果把它放进 onSubmit 的依赖数组,每次切换都重建 onSubmit → PromptInput props 变化 → 整个输入区域 re-render。解法是用 ref 镜像,回调通过 ref 读,React 渲染不感知这个变化。

双流渲染useDeferredValue(messages) 产生一个延迟版本的消息列表。流式响应期间,Spinner 和输入框用实时的 messages,消息列表用延迟的 deferredMessages,这样长列表的 reconciliation 不会卡住输入。但当流式文本正在显示或查询结束时,又切回实时消息,避免"动画停了但回复还没出来"的闪烁。

const usesSyncMessages = showStreamingText || !isLoading;
const displayedMessages = usesSyncMessages ? messages : deferredMessages;

这种条件切换的思路比无脑 useDeferredValue 精细不少。

注释质量

我读过不少开源项目的代码,这个文件的注释水平是第一梯队的。不是"设置 loading 为 true"这种废话注释,而是记录"为什么"和"不这样做会怎样":

// Josh Rosen's workflow: Claude emits long output → scroll
// up to read the start → start typing → before this fix, snapped to bottom.
// https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739
const RECENT_SCROLL_REPIN_WINDOW_MS = 3000;

一个常量附带了:具体的用户场景(谁遇到了什么问题)、修复前的行为、内部讨论链接。半年后新人看到这段代码,不用猜为什么是 3 秒。

另一个:

// Without this, paths that queue functional updaters then
// synchronously read the ref (e.g. handleSpeculationAccept →
// onQuery) see stale data.

直接告诉你:不加这行,具体哪个调用链会读到脏数据。这种注释的信息密度比代码本身还高。

中断后自动恢复

用户按 Esc 中断 Claude 的回复时,如果 Claude 还没产生什么有用的内容,REPL 会自动回退对话、恢复你之前输入的文字,省去重新打字的麻烦。

实现上卡了 5 个条件:中断原因必须是用户主动取消(不是程序性中断)、没有新查询在跑、输入框是空的(不覆盖用户已经开始打的新内容)、命令队列是空的、不在看 teammate 的视图。

这种细节不是架构层面的东西,但直接影响日常使用的手感。能把这种 edge case 一个个堵住,说明有大量真实使用反馈在驱动。

Idle-Return 提示

用户离开超过 75 分钟、对话已消耗超过 10 万 token 时,下次输入会提示"要不要 /clear 开个新对话"。

长对话的 KV cache 已经冷了,继续追加 token 成本高、响应质量也可能下降。但这个提示不是硬拦——支持阻断式弹窗和非阻断式通知两种形态,通过 A/B 测试(GrowthBook)切换,用户还能永久关掉。把成本优化做成了用户体验优化,不让人觉得"系统在限制我"。


问题

God Component

这是最大的问题,没有之一。

REPL 函数从第 572 行开始,到第 5004 行 return。中间塞了:

  • 会话管理状态(messages, conversationId, sessionTitle)
  • UI 状态(screen, showAllInTranscript, dumpMode, editorStatus)
  • 输入状态(inputValue, inputMode, pastedContents, vimMode)
  • 加载状态(queryGuard, isExternalLoading, streamMode, streamingToolUses)
  • 弹窗队列(toolUseConfirmQueue, promptQueue, sandboxPermissionRequestQueue)
  • 10+ 种 focusedInputDialog 类型

getFocusedInputDialog 函数(第 2017 行)是一个 30 多行的 if-else 优先级链,决定当多个弹窗同时需要显示时哪个获得焦点:

exit > message-selector > (输入抑制) > sandbox-permission >
tool-permission > prompt > worker-sandbox > elicitation > cost >
idle-return > ultraplan > ide-onboarding > model-switch > ...

本质上是在手动实现状态机,但没有用状态机来表达。新增一个弹窗类型时,必须准确地插在这条链的正确位置。

为什么不拆?我猜有几个原因:60+ 个 useState 里大约 40 个被两个以上的回调共享,拆出去就要大量 props drilling 或 context;onSubmitonQuerygetToolUseContext 的回调依赖链很深,跨组件传递会更乱;React Compiler 对大组件做了细粒度缓存,性能惩罚没有传统 React 那么大。

但更可能的真相是:没有人设计了一个 5000 行的组件。它是随功能迭代长出来的。每次加个新功能(voice、swarm、ultraplan、companion sprite),在现有 REPL 里加几个 useState 和一段 JSX 是最快的迭代方式。直到有一天发现已经 5000 行了。

回调依赖爆炸

onSubmit(第 3142 行)的依赖数组有 30 多项。这意味着其中任何一个值变化,整个回调都会重建,进而导致 PromptInput 的 props 变化和下游的级联 re-render。

为了缓解这个问题,文件里造了大量 ref 镜像(onSubmitRefstreamModeRefterminalFocusRef 等),让回调通过 ref 读取而不是闭包捕获。

这本身就是一个信号——当你需要 10 个 ref 来保持一个回调稳定,说明这个回调承担了太多职责。

resume 函数

resume 回调(第 1735 行)有 213 行,执行 20 多个步骤:反序列化消息 → 匹配 coordinator 模式 → 执行 SessionEnd hooks → 执行 SessionStart hooks → 复制 plan → 恢复 file history → 恢复 agent 设置 → 恢复 cost state → 切换 session → 重命名 asciicast → 重置 session file pointer → 清除/恢复 session metadata → 退出/恢复 worktree → 恢复 content replacement → 重置 messages → 清除 input...

这个函数应该是一个独立模块。但它依赖了 REPL 的大量局部状态(readFileStatehaikuTitleAttemptedRefbashTools),想提取出去很困难。这就是 God Component 的典型症状——所有东西都耦合在一起,想拆任何一块都牵一发动全身。

条件 Hook

if (feature('AWAY_SUMMARY')) {
  // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  useAwaySummary(messages, setMessages, isLoading);
}

整个文件有 10 多处这种条件 Hook 调用。feature() 是编译期常量没错,运行时不会变,不会违反 Hook 规则。但这依赖 Bun 的 DCE 正确工作,TypeScript Server 不认识这是常量(标红要 suppress),每个 code review 都要人肉确认"这真的是编译期常量"。

更稳的做法是把条件 Hook 提取为独立组件,用条件渲染代替条件调用:

{feature('AWAY_SUMMARY') && <AwaySummaryProvider messages={messages} ... />}

JSX 的可读性

mainReturn(第 4548 行开始)是一棵巨大的 JSX 树。15 个以上的弹窗组件嵌在里面,每个的 onDone / onResponse 回调直接内联,最长的 onSummarize 有 40 多行。

{focusedInputDialog === 'idle-return' && idleReturnPending &&
  <IdleReturnDialog
    idleMinutes={idleReturnPending.idleMinutes}
    totalInputTokens={getTotalInputTokens()}
    onDone={async action => {
      // 40 行回调逻辑...
    }}
  />}

布局结构被回调逻辑淹没了。改任何一个弹窗的回调,git diff 看起来像改了整个渲染树。想单独测试某个弹窗的行为?不可能,它跟 REPL 的 5000 行状态绑死了。

Magic Numbers 分散

const RECENT_SCROLL_REPIN_WINDOW_MS = 3000;
const PROMPT_SUPPRESSION_MS = 1500;
if (turnDurationMs > 30000 || budgetInfo !== undefined) { ... }
if (count >= 3) return; // autoPermissionsNotificationCount
if (wt.creationDurationMs < 15_000) return; // worktree tip threshold

大部分有命名或注释,但散落在 5000 行的各个角落。想调一个阈值,得先找到它在哪。

错误处理不统一

文件里混用了三种异步错误处理模式:

  1. void someAsyncCall().then(...).catch(...) — 约 20 处
  2. try { await ... } catch { ... } — 约 15 处
  3. void someAsyncCall() 不处理 — 约 5 处

没有统一的策略。某些路径的静默失败可能在极端场景下产生莫名其妙的 bug。

Feature Flag 爆炸

文件里用了 17 个 feature flag:

VOICE_MODE, COORDINATOR_MODE, PROACTIVE, KAIROS, TOKEN_BUDGET,
BRIDGE_MODE, TRANSCRIPT_CLASSIFIER, BG_SESSIONS, MESSAGE_ACTIONS,
ULTRAPLAN, BUDDY, AWAY_SUMMARY, WEB_BROWSER_TOOL, HOOK_PROMPTS,
CONTEXT_COLLAPSE, COMMIT_ATTRIBUTION, AGENT_TRIGGERS

编译期消除保证了运行时不会慢,但源码层面,17 个 flag 理论上有 131,072 种代码路径组合。读代码时脑子里要不断过滤"这段在外部构建里存不存在",心智负担不小。


几个有意思的设计细节

Telemetry 的类型约束

logEvent('tengu_session_resumed', {
  entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  success: true,
});

AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 这个类型名是认真的。它强制每个埋点调用者通过 as 断言来确认"我检查过了,这个值里没有用户代码或文件路径"。Code review 时看到这个断言就知道要额外关注隐私合规。用类型系统来编码安全策略,思路很好。

统一的去重模式

文件里到处都是 ref 做的一次性守卫:

  • tipPickedThisTurnRef:防止 resetLoadingState 执行两次时重复选 spinner tip
  • hasCountedQueueUseRef:防止 saveGlobalConfig 的写风暴(并发会话下会打架)
  • idleHintShownRef:每会话只显示一次 idle 提示
  • safeYoloMessageShownRef:auto mode 提示最多显示 3 次

模式一样,但每次都手写。如果提取个 useOncePerTurnuseGuardedEffect 会干净很多。

远程模式的统一抽象

const activeRemote = sshRemote.isRemoteMode
  ? sshRemote
  : directConnect.isRemoteMode
    ? directConnect
    : remoteSession;

SSH、Direct Connect、WebSocket Remote 三种模式通过相同接口(sendMessagecancelRequestisRemoteMode)抽象。REPL 只跟 activeRemote 交互,不关心底下是什么传输层。没有远程模式时 isRemoteMode 为 false,所有远程代码路径自然跳过。简单有效。

AppState 和 Local State 的分界线

REPL 同时用了 Zustand 风格的全局 store(AppState)和组件内的 useState。分界线不太清晰:

状态 存储位置
messages local useState
toolPermissionContext AppState
streamMode local useState
fileHistory AppState
inputValue local useState
viewingAgentTaskId AppState

大致的规则好像是:需要被子 agent、后台任务、MCP handler 读取的放 AppState,纯 UI 状态放 local。但 messages 作为最核心的状态却是 local 的,通过回调传递给需要的地方。这导致 getToolUseContext 要同时从 store.getState() 和闭包里取数据,两个世界混在一起。


总结

维度 好的方面 不好的方面
规模 功能覆盖完整 单文件过大,认知负担重
性能 系统性优化,不是零敲碎打 部分优化是在弥补架构问题
可读性 注释质量极高 回调嵌套深,JSX 结构被淹没
可维护性 类型安全,编译期 flag 消除 60+ useState 想重构无从下手
错误处理 自动恢复、防御性守卫细致 三种模式混用,策略不统一

如果要给一个评价:这是技术功底很深的人在高速迭代压力下写出来的代码。

每一个 useState 都有存在的理由,每一个 useEffect 都解决了真实的问题,每一段注释都记录了一次 bug 修复或一个产品决策。但当 5000 行积累在一个函数里,整体的可维护性还是不可避免地下降了。

不过话说回来,这可能是工程中最常见也最现实的困境:不是代码写得不好,而是好代码在持续迭代中没有找到结构性重构的时机。写代码的人比谁都清楚这里该拆,但 5005 行的组件和 5005 行的 TODO 之间,前者至少能跑。


说到底,这个项目大概率是 Claude Code 自己迭代自己写出来的。用人类的代码审美去评判一个 AI 写给自己用的代码,多少有点错位。但至少读的过程中能学到不少东西。而且往远了想,也许以后大家真的不用手写代码了,代码只要 AI 自己能看懂就行——到那时候,可读性、可维护性这些标准可能得重新定义了。

基于 Claude Code v2.1.88 源码分析,仅供技术交流。

Win11 抓包工具怎么选?网页请求与设备流量抓取

2026年3月31日 17:23

在 Windows 11 上抓包时,问题一般是当前任务应该用哪一类工具

同样是抓包,不同目标差异很大:

  • 想看浏览器接口
  • 想调试 App 请求
  • 想抓手机流量
  • 想分析 TCP 连接

如果一开始选错工具,就会在配置上面搞半天


一、只想看网页接口就先不用装工具

如果目标是查看网页请求和分析接口返回,直接用浏览器即可。


操作步骤(Chrome / Edge)

  1. 打开网页
  2. 按 F12
  3. 切换到 Network 面板
  4. 刷新页面

可以看到什么

  • 请求 URL
  • 请求方法
  • Header
  • Response

验证

点击某个按钮(例如登录),Network 面板会新增请求。

可以直接点进去查看参数。


二、需要修改请求或重放接口

浏览器工具只能查看,不能灵活修改。

这时需要代理抓包工具,例如:

  • Fiddler
  • Charles(Windows 版)
  • Sniffmaster

在 Win11 上配置 Fiddler

操作步骤:

  1. 启动 Fiddler
  2. 打开 Tools → Options
  3. 开启 HTTPS 解密
  4. 安装证书

让浏览器走代理

Fiddler 会自动设置系统代理。

打开网页后,可以在 Fiddler 中看到请求。


修改请求

  1. 开启断点(Breakpoints)
  2. 发送请求
  3. 修改参数
  4. 再发送

观察变化

修改参数后,服务器返回的数据会发生变化。


三、抓 Windows 本机程序流量

如果目标不是浏览器,而是:

  • 桌面软件
  • 本地客户端

仍然可以用 Fiddler、Charles 或 Sniffmaster。


验证方法

  1. 启动抓包工具
  2. 打开目标程序
  3. 触发网络请求

判断结果

  • 如果出现请求 → 程序走系统代理
  • 如果没有 → 程序未使用代理

四、抓 iPhone 流量(Win11 场景)

如果需要在 Windows 上抓 iPhone 的请求,可以先尝试代理方式。


配置步骤

  1. Win11 上启动 工具
  2. 查看端口(例如 8888)
  3. iPhone 连接同一 Wi-Fi
  4. 在 iPhone 设置代理
  5. 安装证书

端口


测试

用 Safari 打开网页:

  • 如果 Fiddler 有请求 → 配置成功

问题分支

如果 Safari 有请求,但 App 没有,说明 App 没走代理


五、使用数据线直接对设备进行抓包

在 Win11 上,如果代理抓不到移动端流量,可以使用 SniffMaster(抓包大师)


操作步骤

  1. 用 USB 连接 iPhone
  2. 解锁设备
  3. 点击“信任此电脑”
  4. 启动 SniffMaster
  5. 选择设备
  6. 安装驱动(Win11 会提示)
  7. 安装描述文件
  8. 进入 HTTPS 暴力抓包 / 数据流抓包模式
  9. 点击开始

------暴力

观察结果

在界面中可以看到:

  • iPhone 发起的请求
  • 包括未经过代理的流量

https


六、在 Win11 上减少抓包噪音

对设备抓包数据会很多,可以通过筛选降低复杂度。


按 App 筛选

在 SniffMaster 中:

  1. 点击 选择 App
  2. 勾选目标应用
  3. 再触发请求

app选择


再做一次控制变量

  1. 清空记录
  2. 点击开始
  3. 只触发一次操作

这样请求数量会明显减少。


七、分析 TCP / 网络问题

如果问题不是接口,而是:

  • 请求超时
  • 网络波动

可以结合 Wireshark。


操作方式

  1. 在 Win11 上启动 Wireshark
  2. 选择网卡
  3. 开始抓包
  4. 触发请求

可以看到

  • TCP 三次握手
  • 重传
  • 连接断开

在 Win11 上抓包,可以按任务选择工具:

  • 看网页 → 浏览器
  • 改接口 → Fiddler/Sniffmaster
  • 抓 App → 代理工具
  • 抓不到 → SniffMaster
  • 查连接 → Wireshark

多智能体协作 - 使用 LangGraph 子图实现

2026年3月31日 17:39

多智能体协作系统 - 使用 LangGraph 子图实现 功能:并行执行两个智能体(直播文案 + 小红书文案)

未命名绘图.png

import os
from typing import TypedDict, Any, Annotated

import dotenv
from langchain_community.tools import GoogleSerperRun
from langchain_community.utilities import GoogleSerperAPIWrapper
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from pydantic.v1 import Field, BaseModel

dotenv.load_dotenv()

# ==================== 初始化 LLM ====================
llm = ChatOpenAI(
    model="qwen3-max-2026-01-23",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE_URL")
)


# ==================== 工具定义 ====================
class GoogleSerperArgsSchema(BaseModel):
    query: str = Field(description="执行谷歌搜索的查询语句")


google_serper = GoogleSerperRun(
    api_wrapper=GoogleSerperAPIWrapper(),
    args_schema=GoogleSerperArgsSchema,
)


# ==================== 状态归约函数 ====================
def reduce_str(left: str | None, right: str | None) -> str:
    """
    字符串归约函数:用于合并状态字段
    逻辑:如果新值存在且非空则使用新值,否则保留旧值
    !! 如果为空 传递给llm 会造成无限循环
    """
    if right is not None and right != "":
        return right
    return left


# ==================== 状态定义 ====================
class AgentState(TypedDict):
    """主图状态 - 包含所有共享数据"""
    query: Annotated[str, reduce_str]  # 原始问题/商品名
    live_content: Annotated[str, reduce_str]  # 直播文案
    xhs_content: Annotated[str, reduce_str]  # 小红书文案
    messages: Annotated[list, add_messages]  # 对话历史(自动追加消息)


class LiveAgentState(TypedDict):
    """直播智能体状态 - 继承主图所有字段 + messages"""
    query: Annotated[str, reduce_str]
    live_content: Annotated[str, reduce_str]
    xhs_content: Annotated[str, reduce_str]
    messages: Annotated[list, add_messages]


class XHSAgentState(TypedDict):
    """小红书智能体状态 - 继承主图所有字段 + messages"""
    query: Annotated[str, reduce_str]
    live_content: Annotated[str, reduce_str]
    xhs_content: Annotated[str, reduce_str]
    messages: Annotated[list, add_messages]


# ==================== 子图 1: 直播文案智能体 ====================
def chatbot_live(state: LiveAgentState, config: RunnableConfig) -> Any:
    """
    直播文案生成节点
    功能:根据商品名生成直播带货脚本文案,支持调用搜索工具
    """
    # 创建提示模板 + 绑定工具
    prompt = ChatPromptTemplate.from_messages([
        (
            "system",
            "你是一个拥有 10 年经验的直播文案专家,请根据用户提供的产品整理一篇直播带货脚本文案,如果在你的知识库内找不到关于该产品的信息,可以使用搜索工具。"
        ),
        ("human", "{query}"),
        ("placeholder", "{chat_history}"),
    ])
    chain = prompt | llm.bind_tools([google_serper])

    # 调用链生成回复
    ai_message = chain.invoke({"query": state["query"], "chat_history": state["messages"]})

    # 返回更新的状态
    return {
        "messages": [ai_message],  # 追加到消息历史
        "live_content": ai_message.content,  # 更新直播文案
    }


# 创建子图 1 结构
live_agent_graph = StateGraph(LiveAgentState)

# 添加节点
live_agent_graph.add_node("chatbot_live", chatbot_live)  # LLM 聊天节点
live_agent_graph.add_node("tools", ToolNode([google_serper]))  # 工具执行节点

# 添加边(控制流)
live_agent_graph.set_entry_point("chatbot_live")  # 入口点
live_agent_graph.add_conditional_edges(
    "chatbot_live", 
    tools_condition  # 动态路由:如果 LLM 决定调用工具 → tools 节点,否则 → 结束
)
live_agent_graph.add_edge("tools", "chatbot_live")  # 工具执行后返回 LLM

"""
子图 1 流程:
┌─────────┐
│  START  │
└────┬────┘
     │
     ▼
┌─────────────┐
│ chatbot_live│ ←───┐
└──────┬──────┘     │
       │            │
       ├─[需要工具]─→│ tools │
       │            └──────┘
       │
       └─[无需工具]─→ END
"""


# ==================== 子图 2: 小红书文案智能体 ====================
def chatbot_xhs(state: XHSAgentState, config: RunnableConfig) -> Any:
    """
    小红书文案生成节点
    功能:根据商品名生成小红书笔记文案(风格活泼,带 emoji)
    """
    # 创建提示模板 + 解析器
    prompt = ChatPromptTemplate.from_messages([
        ("system",
         "你是一个小红书文案大师,请根据用户传递的商品名,生成一篇关于该商品的小红书笔记文案,注意风格活泼,多使用 emoji 表情。"),
        ("human", "{query}"),
    ])
    chain = prompt | llm | StrOutputParser()

    # 调用链生成文案
    return {"xhs_content": chain.invoke({"query": state["query"]})}


# 创建子图 2 结构
xhs_agent_graph = StateGraph(XHSAgentState)

# 添加节点
xhs_agent_graph.add_node("chatbot_xhs", chatbot_xhs)

# 添加边
xhs_agent_graph.set_entry_point("chatbot_xhs")  # 入口
xhs_agent_graph.set_finish_point("chatbot_xhs")  # 出口


子图 2 流程:
┌─────────┐
│  START  │
└────┬────┘
     │
     ▼
┌────────────┐
│ chatbot_xhs│
└──────┬─────┘
       │
       ▼
    END



# ==================== 主图:编排两个子图 ====================
def parallel_node(state: AgentState, config: RunnableConfig) -> Any:
    """
    并行分发节点
    功能:透传状态,将请求分发给两个子智能体
    """
    return state


# 创建主图结构
agent_graph = StateGraph(AgentState)

# 添加节点(关键:添加的是编译后的子图)
agent_graph.add_node("parallel_node", parallel_node)  # 分发节点
agent_graph.add_node("live_agent", live_agent_graph.compile())  # 直播智能体(子图)
agent_graph.add_node("xhs_agent", xhs_agent_graph.compile())  # 小红书智能体(子图)

# 添加边(控制流)
agent_graph.set_entry_point("parallel_node")  # 从分发节点开始
agent_graph.add_edge("parallel_node", "live_agent")  # 并行执行直播智能体
agent_graph.add_edge("parallel_node", "xhs_agent")  # 并行执行小红书智能体

# 设置结束点(两个子图都完成后结束)
agent_graph.set_finish_point("live_agent")
agent_graph.set_finish_point("xhs_agent")


# 编译主图
agent = agent_graph.compile()

# 打印图的 ASCII 结构
print(agent.get_graph().print_ascii())

# 执行并获取结果
print("\n=== 执行结果 ===")
result = agent.invoke({"query": "潮汕牛肉丸"})
print(f"商品:{result['query']}")
print(f"\n直播文案:\n{result['live_content']}")
print(f"\n小红书文案:\n{result['xhs_content']}")

Everything Claude Code:让我把 AI 编程效率再翻一倍的东西

作者 清汤饺子
2026年3月31日 09:32

Hi~大家好呀,我是清汤饺子。

先说个让我差点砸键盘的场景。

我打开一个新的 Claude Code session,准备继续前天没写完的功能。

Claude 热情地跟我打招呼:嗨!很高兴再次见到你,有什么我可以帮你?

我说:继续前天的任务。

Claude:好的!请问你想做什么?

我:就是那个功能模块啊,前天做到一半的那个。

Claude:好的!请问你想做什么功能?

我:……你刚才不是说了"再次见到我吗"?

Claude:哦,那只是客气话,我的记忆撑不过一个 session。

我:……行吧。

这个对话你是不是也似曾相识?

是不是也想问 AI:你礼貌吗?

一、我的痛点:AI 每次都是"新人"

Claude Code 的 memory 功能我深度用过——CLAUDE.md 配了、项目规范配了、技术栈配了。

但它只能记住"静态上下文",记不住"动态进度" :上次做到哪了、上次做了什么决定、上次遇到了什么问题。

更崩溃的是——有时候 Claude 会"选择性失忆"。明明配置了 memory,它偏偏没触发。有一次我让它帮我重构一个模块,它完全忽略了我们的代码规范,输出了一套我完全不认识的风格。

我开始认真想:有没有一套系统,能让 AI 的"记忆"真正 work?

然后我发现了 Everything Claude Code。

二、ECC 是什么

GitHub 110K+ stars,Anthropic Hackathon 冠军作品。

作者是 affaan-m,做了 10 个月每天高强度在真实项目里打磨出来的。定位不是"配置文件合集",而是:一套完整的 AI Agent 性能优化系统

ECC 官方有一张对比表,说清了它的核心价值:

Without ECC With ECC
AI 不了解团队的代码模式 AI 通过 rules 和 skills 学习团队规范
测试靠手动写,覆盖率不稳定 TDD 流程内置,测试先行,覆盖率透明
安全漏洞靠人工 review AgentShield 实时扫描,102 条规则自动拦截
团队没有统一的代码标准 skills 和 agents 全团队共享
每个 session 从零开始 Continuous Learning 跨 session 积累

这张表说清楚了 ECC 解决的问题。但光看表感受不深,我用了两个月,说说具体是什么体验。

三、GitHub App:把 commit 历史变成团队规范

这是 ecc.tools 最让我惊喜的功能。

ECC 提供一个 GitHub App(免费安装),它的工作方式是这样的:

  1. 在你的仓库安装 ECC Tools GitHub App
  2. 在任意 issue 下评论 /ecc-tools analyze
  3. ECC 自动分析你的 commit 历史、代码模式、团队规范
  4. 自动生成一个 Pull Request,把这些历史转成 skills 和 defaults

翻译成人话:你的 git 提交记录里藏着团队多年的工程经验,ECC 自动把这些经验提取出来,变成 AI 可以复用的规范。

这个 PR 不是直接合并的——你审核、修改、确认之后才生效。完全可控。

我试了一下,第一次跑完它生成了大约 30 条 rules,覆盖了我们的 commit message 规范、分支命名规则、还有 API 错误处理的一些惯用模式。

最厉害的是:这个 PR 里的内容是专门针对你这个仓库的,不是通用模板。ECC 读的是你真实的 git 历史,提取的是你团队真实在用的规范。

四、Token 优化:让 AI 跑得更快

Context window 是有限的,AI 跑着跑着就开始"失忆"前面的内容。ECC 有几个实用的省 tokens 方法,都是踩坑踩出来的经验。

  • 模型选择:大多数日常任务用 Sonnet 4.5 就够了,复杂任务(跨 5+ 文件、架构决策、安全关键代码)升 Opus,重复性劳动降级到 Haiku 当 worker。类比一下:能用摩托车拉的不用卡车,卡车油耗高,还不好停。

  • 工具替换:Claude 默认用 grep 或 ripgrep 搜索代码,tokens 消耗大。换 mgrep,平均节省 50%——就像从手动档换成自动挡,不改变目的地,但脚不酸了。

  • 后台进程:不需要 AI 实时处理输出的任务,用 tmux 丢后台,不占用 context。这就像让 AI 同时处理多项任务——实际上它是把不重要的任务先寄存起来。

  • 模块化代码库:文件越小(几百行 vs 几千行),AI 消耗的 tokens 越少,出错率也越低。

五、Memory 持久化:AI 不再是"金鱼"

这是 ECC 最打动我的功能,也是它和"普通配置文件"的本质区别所在。

原生 Claude Code memory 只能存"静态模板"——项目规范、技术栈、代码风格。但它存不住"动态进度" :上次做到哪了、遇到了什么问题、做了什么决策。

ECC 的解法是把三个 Hook 串联起来,形成完整的记忆链条。

第一棒:SessionComplete Hook,session 结束时自动存档

Session 结束时,Claude 自动把当前状态写入 .tmp 文件——完成的任务、遇到的阻塞、关键决策、下次继续需要的信息,全都存下来。

第二棒:SessionStart Hook,新 session 开始时自动恢复

新 session 开始时,Claude 自动读取上次的 .tmp 文件。它会主动问:"检测到上次有未完成的任务,要继续吗?"

第三棒:PreCompact Hook,提前预警该整理了

在你积累了很多上下文的时候,提前提示你"该整理一下了",避免等到 AI 开始"失忆"才后悔。

三个 hook 串联起来,实现的是:跨 session 真正零手动干预的连续记忆。我第一次用这套组合的感觉是——Claude 终于不是"金鱼"了,甚至有点像一个记性比我还好的 senior。

六、Continuous Learning:让 AI 从错误中进化

核心问题:同一个错误,AI 犯一次两次三次,永远记不住。

解法:告诉 Claude "记住它",它把这个模式自动写入 skills,下次遇到类似场景自动调用。

触发方式有两种:

自动:session 结束时运行 /learn,自动提取这次 session 里发现的有效模式。

手动:中途解决了什么非平凡的问题,马上 /learn 即时提取。

我连续三次让 Claude 帮我写 API 接口,它第三次就自己学会了"我们项目里 API 文件放哪里、命名规范是什么、错误处理用什么模式"。

这感觉就像养成了一个会自动学习的好习惯——不用催,它自己就记住了

七、验证与安全

AI 执行命令是有风险的。Prompt injection、未经授权的文件修改、"AI 误删整个 node_modules"这种事,社区里见过太多了。

解法:ECC 提供了 AgentShield——一个独立的安全扫描工具,102 条规则、1282 个测试用例、98% 覆盖率,采用 Red Team / Blue Team / Auditor 三层 Pipeline。

这阵容,比很多公司的安全团队都专业。

效果:扫描输出分级展示,critical 问题直接标红。

运行效果是这样的:

$ npx ecc-agentshield scan ./CLAUDE.md

 CRITICAL  Unrestricted file system access via Bash tool
 WARNING    No rate limiting on external API calls
 WARNING    Missing secret detection guardrail
 PASS       Tool permissions properly scoped
 PASS       Destructive action confirmation required
 PASS       No prompt injection vectors detected

Security Score: 72/1001 critical, 2 warnings, 3 passed
Full report saved to ./agentshield-report.json

我之前差点让 Claude 把整个 node_modules 删了——它问都没问我直接动手。幸好当时没执行,不然一天白干。有 AgentShield,那种"先斩后奏"的命令直接被拦截,连求情的机会都不给

八、技术原理

看完 GitHub 仓库,我发现 ECC 比"配置文件合集"要系统得多。它的核心不是某一个功能,而是一套层次化的 Agent 优化架构

1. 五类组件:底层基础设施

ECC 的仓库由五类组件构成,每一类解决不同层次的问题:

  • Agents(智能体):30+ 专业子代理,负责特定领域的任务执行。比如 code-reviewer 专门做 code review,build-error-resolver 专门修编译错误,chief-of-staff 专门做任务规划和进度管理。每一个 agent 只做一件事,做得很专注。

  • Skills(技能):可复用的任务模式库,分两类——

    • 语言生态:TypeScript、Python、Go、Rust、Java、PHP、Perl、Kotlin、C++ 等,每个语言有对应的 patterns 和 conventions
    • 垂直领域:django、laravel、springboot、pytorch 等框架完整技能栈,覆盖从开发到部署的全流程
  • Commands(命令):斜杠命令是快速触发技能的入口,比如 /plan 触发任务规划、/tdd 启动 TDD 流程、/learn 即时提取好模式。命令和技能联动,构成了 ECC 的交互层。

  • Rules(规则):始终遵循的约束,放置在 .claude/rules/ 目录下。AI 每个 session 都会读取,是最低层次的"铁律"。Rules 不同于 Skills——Skills 是告诉 AI"怎么做",Rules 是告诉 AI"绝对不能怎么做"。

  • Hooks(钩子):挂在 Session 生命周期上的自动化脚本,这是 ECC 最具特色的设计。每个 Hook 有明确的触发时机:

    • PreToolUse:工具执行前触发,比如拦截危险命令
    • PostToolUse:工具执行后触发,比如自动格式化、自动运行 lint
    • SessionComplete:session 结束时触发,自动存档
    • SessionStart:session 开始时触发,自动恢复上下文
    • PreCompact:上下文即将溢出前触发,提前预警

2. SKILL.md:技能自动发现的秘密

ECC 的 Skills 不是靠手动调用的,而是靠 description 字段自动触发

每个 Skill 文件(Markdown 格式)顶部有一段 YAML metadata:

---
name: tdd-workflow
description: Use when the user wants to do test-driven development - sets up TDD flow with RED first
---

当 AI 判断当前任务符合 description 的条件时,自动激活对应 Skill,整个过程不需要你做任何事情。

这意味着 ECC 的 Skills 系统本质上是上下文感知的——AI 根据当前任务状态自动匹配最佳实践,而不是等你一步步指示。

3. SQLite 状态存储:持久化的秘密

ECC 用 SQLite 作为状态存储数据库,记录:

  • 已安装的 Skills 列表和版本
  • Session 历史摘要
  • Continuous Learning 的演化记录
  • 各平台(Claude Code / Codex / Cursor / OpenCode)的配置状态

这让 ECC 具备"状态记忆"——不是每次从零开始,而是知道"上次装了什么、上次做了什么、哪里出了问题"。支持增量更新,不用每次全量重装。

4. Continuous Learning 的技术实现

ECC 的 Continuous Learning 不是靠"更长的 context window",而是靠自动提取 + 写入 Skills 目录

  1. AI 在 session 中发现一个有效的模式(比如"这个项目的 API 错误处理用 Result type")
  2. 自动把这个模式写入 ~/.everything-claude-code/skills/
  3. 下次遇到类似场景,Skills 触发,模式复用

本质是把隐性知识显性化,把单次经验变成可复用资产。这解决了 AI "同类错误重复犯" 的根本问题。

5. AgentShield 的技术实现

AgentShield 不是简单的"危险命令黑名单",而是一套多层次安全扫描机制

  • Hook 层:在命令执行前拦截,扫描 rm -rfcurl | bashgit push --force 等危险模式
  • CVE 数据库:集成了常见漏洞数据库,扫描依赖包是否有已知漏洞
  • Sandbox 隔离:危险操作在隔离环境执行,不直接影响主项目

6. 安装架构:Manifest 驱动

ECC 的安装不是"一键全装",而是Manifest 驱动的选择性安装

./install.sh --profile full        # 全量安装
./install.sh typescript            # 只装 TypeScript 相关
./install.sh --target cursor python  # 只给 Cursor 装 Python 生态

install-plan.jsinstall-apply.js 负责解析 Manifest,按需安装。SQLite 状态存储记录"装了什么",支持增量更新。ECC_HOOK_PROFILE 环境变量还可以控制 Hook 的严格程度(minimal / standard / strict)。

GitHub 仓库:github.com/affaan-m/ev…

九、和 Superpowers + OpenSpec 的关系

这三个工具解决的问题正好互补:

工具 解决的问题
OpenSpec 需求对齐——先签字再动手
Superpowers 工程纪律——TDD、task 分解、子 Agent 编排
ECC 性能和记忆——Token 优化、Memory 持久化、安全扫描

OpenSpec 在最上游——它管的是做什么

Superpowers 在中游——它管的是怎么做

ECC 在底层——它管的是怎么跑得更好

三个一起用,才是完整的 AI 编程工作流。


写在最后

ECC 解决了一个根本问题:AI 不是"真的智能",它是"真的没有记忆"

110K+ stars 说明这套方法论经过了大量开发者的验证。我用了两个月,最大的感受是——AI 编程终于有点像"和一个靠谱的同事合作",而不是"和一个热情的实习生搏斗"——热情是热情,但每次都要我来收拾残局。

当然它不是银弹。配置成本不低,学习曲线陡峭,踩坑也需要时间。如果你每天用 AI 写代码,这点投入值得;如果只是偶尔用用,原生体验可能就够了——省下的配置时间够你手动写好几屏代码了

你被 AI coding 的"失忆症"困扰过吗?有没有什么土办法?

欢迎评论区聊聊,看看大家都有什么奇葩经历,互相种草避坑。

如果觉得有帮助,点个赞收藏一下,我会更有动力更新下一期。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

如果想转 AI 全栈?推荐你学一下 Langchain!

作者 Moment
2026年3月31日 08:10

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

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

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

image.png

上个月月底,我去参加了一场在深圳举办的线下聚会,现场人很多,几乎称得上爆满,分享具体讲了什么我其实没有认真听完,但有一个现象让我印象特别深。

我发现,现场已经有很多并非技术出身的人在真实地使用 AI 做开发,有人是产品经理,有人甚至没有完整的软件工程背景,但他们一样能借助 Claude CodeCursor 这类 AI 编辑器,把一个产品从想法推进到可运行的形态。

只要你真正用过这类工具,你就会知道它们强在哪里,很多时候你不必先把所有代码写完,只要把问题、目标和约束说清楚,模型就能替你完成相当大一部分工作,它不光是在替你补几行代码,更是在把你的想法翻译成可执行的过程。

这件事带来的冲击其实很直接,不是只有程序员才能做产品了,而是谁更会拆问题、谁更会组织上下文、谁更会调度 AI,谁就更有机会把事情做成。

所以,真正需要警惕的从来不是 AI 会不会写代码,而是你是否还停留在只会发一个 chat.completions 请求、然后等它吐一段文本的阶段,因为当 AI 开始参与真实任务时,竞争点已经不再只是会不会调模型,而是你能不能把模型接进系统、接进流程、接进业务,最后让它稳定地把事做完。

也正因为如此,这套文档不会停留在教你调用一下 LLM API 这一层,它想解决的是更往前一步的问题,当 AI 不再只是聊天,而是真正进入你的产品、流程和工程系统里时,你到底该怎么设计它、约束它、组织它、编排它。

从会调模型到能改整条 Agent 链路

理想状态大概是,你不再满足于发完请求就收一段文本,而是能把一条真正可执行的 Agent 链路说清楚,别人问起来,你也知道该动哪一层、从哪下手改。

这里不会拿概念填空来凑篇幅,那些词你多半已经见过。更值得花时间的是落地之后一定会撞上的事,比如上下文该留什么、该砍什么,模型才既记得住关键信息,又不会被历史拖垮。工具怎么写、Function Call 怎么接,才能少空转、少胡编,多把事办完。结构化输出怎么定,业务里才能当真数据用,而不是靠正则和运气硬接。

再往后,中间件、护栏、运行时、上下文工程各自兜的是哪一类坑,MCP 这类协议又该摆在协作架构的哪一层。人机协同、多 AgentSubagentsHandoffsSkillsRouter、自定义工作流,听起来多,其实都是在不同复杂度下选一条路。至于 CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 这些名字,背下来没多大用,有用的是它们背后控制流怎么画、推理预算该多给还是该省。

章节一路跟下来,术语和框架名自然会熟,但更值得带走的是一种手感。某类任务该用简单的 Agent 循环还是上图式编排,某段流程要不要上人审、要不要拆角色,某一步老是失败时,该补护栏、补记忆、补工具描述,还是干脆换一套推理策略。能分清这些,比多记十个 API 名字实在得多。

真正花时间的是把系统搭稳

网上讲 AI 开发的内容已经很多,常见的却两头偏,一头概念讲得热闹,回到工程里不知道该动哪只手,另一头 demo 复制粘贴能跑,一进真实业务就开始散。

第一次把结果跑出来的时候,你往往还觉得挺顺。你很快会发现,真正难的从来不是让它第一次跑起来,而是:

  • 为什么这个 Agent 一到复杂任务就开始乱
  • 为什么多轮之后上下文越来越脏
  • 为什么工具明明接了,模型还是不会正确调用
  • 为什么结构化输出看起来像 JSON,实际上却根本不稳定
  • 为什么接了很多能力,系统却越来越难控、越来越难测、越来越难上线

这套文档想把这一串问号拆开来看。重点不单是让模型答得更聪明,而是让你看清一个能进生产环境的系统底下有几层、每层在扛什么,出事该往哪一层摸,而不是遇事就把锅甩给模型不够聪明。

如何学习

按章节顺序读就行,不是要你迷信目录,而是后面的例子会默认你已经看过前面的概念,跳太狠容易半路卡住。

开头一大段都在打基础,裸调模型哪里别扭、LangChain 在补什么、Function Call、消息结构、工具怎么接、先跑一个最简单的 Agent、再加上会话记忆和结构化输出。拆开看是很多篇,合起来就是在说一件事,模型是怎么被接进一条可执行的链路里的。

再往后会硬一些,主要对付"能跑"和"敢上线"之间的差距,中间件、护栏、运行时、上下文工程、MCP、人机协同、多 Agent,以及 SubagentsHandoffsSkillsRouter、自定义工作流之类。名字多,你不用全记住,先有个印象,知道这些多半是在管权限、管边界、管出事以后谁来兜底。

后面才轮到规划、反思、试探、回退这类话题。CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 当几种不同的走法看就好,定义背了也没多大用。有用的是下面这些判断,心里过一遍比抄名词强:

  • 什么场景下值得多给一点推理预算
  • 什么场景下应该尽快落工具、少走内耗
  • 什么任务适合先规划后执行
  • 什么任务反而应该边做边修正
  • 什么情况下多想一步是收益,什么情况下只是成本

快收尾的时候会把长期记忆和 harness 拉出来,把执行、状态、持久化、审计、可观测性这些零散提过的东西并到一块,方便你对照真实环境里一般长什么样。

20260329233412

整体就是这样,先把基础概念和常见拼法摸熟,再啃工程和协作里那些让人心里发虚的部分,最后在控制流和收尾方式上收个口。

适合谁、怎么读

你若是写 React、做业务、跟需求,模型 API 也碰过,却越来越觉得卡不在页面上,而在模型怎么接、工具怎么配、多步任务怎么串,这一路的写法就是按这个感觉排的。

做过聊天框、demo,想再往"能办事"那边挪一步,也会对上号。别人做出来的像助手,自己的还在一问一答里打转,这类落差在这里会当成工程问题拆,而不是甩一句模型不够聪明。

还有一种情况,文章东一篇西一篇看过,记忆、工具调用、Agent 都见过词,就是拼不出一张图。按章节往下翻,多半能把那些散点接回一条线。

读法上可以松一点,不必一次啃完。过完一章,想想自己项目里有没有同款糟心事,有的话最小改动可以先动哪一步。理论不用第一遍就全吃透,能慢慢把问题和章节里的招对上,就已经在读对路了。

Tauri 2 iOS 开发避坑指南:文件保存、Dialog 和 Documents 目录的那些坑

作者 ssshooter
2026年3月30日 23:06

很多开发者在把 Tauri 2 应用上架到 iOS(真机或模拟器)时,都会在文件保存这一步踩坑:明明代码代码在其他平台没问题,在 iOS 路径就返回 null,或者在「文件」App 里根本看不到自己的 App 文件夹。

下面我把最常见的几个坑总结成一份避坑科普文,帮你一次性避开这些“iOS 特色”问题。

坑 1:@tauri-apps/plugin-dialogsave() 在 iOS 上经常返回 null 或路径不可用

现象
调用 const path = await save({...}) 后,一个 0KB 的文件写入成功,但是 path 返回是 null

避坑方法

  • 不要过度依赖 dialog.save() 来实现“用户任意选择保存位置”。
  • 优先使用 直接写入 App 的 Documents 目录(见坑 3)。
  • capabilities 中确保开启 dialog:save 权限。

坑 2:文件明明写入了,但「文件」App 里完全看不到 “Mind Elixir” 文件夹

现象: 用了 BaseDirectory.Document 保存文件后,在「文件」App → 浏览 → On My iPhone 里找不到你的 App 文件夹。

原因: iOS 沙盒机制严格控制 App 的 Documents 目录是否对「文件」App 可见。Tauri 默认生成的 iOS 项目不会自动添加暴露文件夹的配置,就算你写再多文件,文件夹也不会出现。

避坑方法(最关键的一步): 在 Info.plist 中添加以下两个 key(必须同时添加):

<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

位置:通常在 src-tauri/gen/apple/ios/App/App/Info.plist(或你的项目对应路径),加在 <dict> 标签内,</dict> 之前。

添加后必须重新构建并安装 Appcargo tauri ios build 或用 Xcode 编译),然后:

  • 先执行一次写入操作(创建文件)。
  • 完全退出「文件」App(上滑关闭),重新打开并下拉刷新「On My iPhone」。

此时你应该能看到和 App 同名的文件夹(显示名称来自 productName 或 Xcode Display Name)。

注意:这两个 key 只控制可见性,不影响代码读写。

坑 3:iOS 上最好的保存方式其实不是 dialog,而是直接用 Documents 目录

推荐做法

import { writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'

await writeTextFile('my-note.md', '你的内容...', {
  dir: BaseDirectory.Document
})

优点:

  • 最稳定,几乎不会出现 0 字节文件的问题。
  • 用户可以在「文件」App 里直接看到和管理文件(添加上面两个 plist key 后)。
  • 无需处理复杂的 URI 和潜在的 fs bug。

如果你想让用户输入文件名,可以结合 prompt 或自定义输入框实现。

总结建议

在 Tauri 2 + iOS 开发中:

  1. 优先使用 BaseDirectory.Document 直接保存(最稳)。
  2. 必须在 Info.plist 添加 UIFileSharingEnabledLSSupportsOpeningDocumentsInPlace
  3. 谨慎使用 dialog.save() + writeFile,因为移动端兼容性还有待完善(官方 issue 仍在跟进)。
  4. 开发时多用控制台日志 + Safari/XCode 调试,遇到路径问题先检查 plist 和权限。

避开这几个坑后,你的 Mind Elixir(或其他 App)在 iOS 上的文件保存功能就会顺畅很多。iOS 的沙盒和文件系统规则和桌面差异很大,提前了解这些“Apple 特色”能省下大量调试时间。

(本文基于 Tauri 2 常见 issue 和实际开发经验总结,iOS 规则可能随系统版本微调,建议以 Apple 官方文档为准。)

低代码何时能出个“秦始皇”一统天下?我是真学不动啦!

2026年3月30日 18:02

前端有战国七雄,低代码圈更是“百国千城”

引言

低代码开发平台的世界,如今就像春秋战国时期的诸侯割据——各种平台、各种引擎、各种规范层出不穷,表面上都是“让开发更简单”,实际用起来却各有各的“方言”,各有各的“城墙”。

更让人头疼的是,因为各家有各家的技术路线,连选型都成了一场赌博

做OA的推工作流引擎强大,做ERP的强调数据模型灵活,做移动端的鼓吹多端适配能力,做报表的说自己可视化最牛……

甲方想统一技术栈,结果发现不同部门已经用了三四种低代码平台,数据不通、流程不通、权限不通,比全代码开发还乱。

低代码平台之争:各有各的“山头”

目前市面上的低代码平台,大致可以分为几大流派:

企业级应用平台

  • 代表:OutSystems、Mendix、Salesforce

  • 特点:功能全面,适合大型企业复杂业务,但价格昂贵、学习曲线陡峭

  • 定位:高端市场,私有化部署为主

国内主流厂商

  • 代表:JNPF、明道云、简道云、氚云

  • 特点:贴近国内企业管理习惯,支持钉钉/企微集成,性价比高

  • 差异:有的侧重表单流程,有的侧重数据中台,有的侧重ERP扩展

开源低代码

  • 代表:Appsmith、Budibase、Saltcorn

  • 特点:代码透明,可二次开发,但企业级功能(如复杂工作流、高并发)往往需要自研补齐

云厂商自研

  • 代表:阿里宜搭、腾讯微搭、华为AppCube

  • 特点:与云生态深度绑定,适合该云平台上的企业使用

对比分析:

  • 低代码平台目前没有统一标准。一个平台设计的应用,基本无法迁移到另一个平台,厂商锁定问题突出。

  • 每个平台都有自己的一套“元数据规范”“表达式语法”“API设计风格”,团队切换平台几乎等于推翻重来。

  • 选型时不仅要看功能,还要评估开放性、扩展能力、私有化支持,避免未来被单一厂商绑定太死。

工作流引擎:百花齐放,各立山头

工作流是低代码的核心能力之一,也是“分裂”最严重的领域。

开源工作流引擎

  • Activiti / Flowable / Camunda:BPMN 2.0标准的三巨头,各有各的版本分支和API风格。

  • 选一个引擎,意味着团队要学习该引擎的变量设计、监听器写法、部署方式,后期替换成本极高。

低代码平台内置工作流

  • 各家基本都宣称“可视化流程设计”,但设计器体验、节点能力、与其他模块的集成深度天差地别。

  • 有的平台流程和表单是割裂的,有的平台流程引擎无法独立于平台使用。

BPM厂商产品

  • 如IBM BPM、Pega,功能强大但价格昂贵,主要服务超大型企业。

对比分析:

  • 工作流领域“学不动”的根源在于:每个引擎都有自己的“方言”,即便都支持BPMN 2.0,在具体实现细节、扩展方式上也差异巨大。

  • 企业一旦选定,后续调整和升级都要围绕该引擎的生态展开,迁移成本极高。

表单设计器与UI渲染:各有各的“积木”

表单是用户与系统交互的界面,这一块同样是“诸侯割据”:

开源表单设计器

  • Formily、FormGenerator、VForm……每个设计器产出的JSON Schema结构不同,渲染引擎互不兼容。

低代码平台自带设计器

  • 有的平台提供纯Web可视化拖拽,有的则需要开发者编写少量代码来扩展组件。

  • 组件的封装粒度、属性配置方式、事件绑定机制,各家千差万别。

UI组件库阵营

  • 基于Ant Design、Element Plus、Naive UI等组件库的低代码平台,生成的代码风格迥异。

对比分析:

  • 表单和UI这一块,统一的可能性最低,因为UI本身就是一个审美和习惯差异巨大的领域。

  • 但企业真正需要的是:设计出来的表单能稳定运行,字段权限与工作流、数据权限自动联动,而不是只停留在UI层面。

集成与扩展:每个平台都是一座“孤岛”

低代码平台最怕的不是功能不够,而是无法融入企业现有的技术生态

  • 数据层:有的平台只能使用内置数据库,有的支持外部数据源,但支持的数据库类型和连接能力差异很大。

  • API层:有的平台提供REST API可反向调用,有的只能通过平台内触发器调用外部接口,且鉴权方式五花八门。

  • 前端扩展:有的允许写自定义代码嵌入页面,有的只能使用平台提供的组件,无法引入第三方库。

  • 后端扩展:有的支持云函数/脚本,有的完全封闭,只能使用平台内置逻辑。

对比分析:

  • 如果平台在集成扩展能力上过于封闭,那么随着业务复杂度的提升,最终还是会回到全代码开发的老路上,低代码反而成了“先甜后苦”的选择。

JNPF的“合纵”思路:不争引擎争生态

面对这个“百国千城”的局面,JNPF选择了一条不同的路——不试图用一套引擎取代所有,而是用开放生态减少内耗

统一的底层架构,避免重复造轮子

JNPF提供了一体化的技术底座:从用户组织、权限中心、工作流引擎、表单设计器、报表设计器到代码生成器,全部基于同一套元数据规范和数据模型。企业不再需要为了“工作流用一个引擎、表单用一套设计器、报表用一个工具”而维护多套技术栈。

开放性与扩展性,不做“孤岛”

  • 数据层:支持MySQL、SQL Server、Oracle、PostgreSQL等主流数据库,并可对接外部数据源,避免数据孤岛。

  • 后端扩展:支持Java、C#双语言版本,并提供代码生成器,复杂业务可以编写原生代码,与平台无缝集成。

  • 前端扩展:支持自定义组件嵌入,可以引入第三方UI库或业务组件,不被平台设计器限制。

  • API层:提供完整的REST API,平台内的功能均可通过API调用,方便与现有系统集成。

工作流引擎的“实用主义”

JNPF工作流引擎基于成熟内核,但重点不在“引擎本身多强”,而在于与表单、权限、消息、第三方系统的开箱即用集成。业务人员画完流程,自动关联表单权限、自动同步组织架构、自动对接钉钉/企微消息,开发人员无需在集成上重复消耗精力。

可私有化、可掌控

对于中大型企业,JNPF支持全源码交付,企业可以获得完整的平台代码,自主部署、自主维护、自主二次开发。既享受了低代码的开发效率,又保留了技术自主权,避免被厂商锁定。

低代码圈的统一,可能不在引擎层面

前端领域这么多年都没等来“秦始皇”,低代码圈的统一可能也不是靠一个平台吞并所有。

真正的“统一”,或许是:

  • 标准层面的趋同:比如元数据规范、API设计模式逐渐形成事实标准。

  • 开放生态的普及:更多平台像JNPF一样,不再强求“全用我的”,而是提供良好的开放能力,让企业能够按需组合、平滑演进。

  • 企业意识的成熟:选型时不再只看“功能列表多全”,而是看“能不能与现有系统共存”“能不能长期可控”。

JNPF的实践表明:与其在引擎层面争高下,不如在生态层面做整合。一个平台如果能做到——核心稳定、开放可控、集成顺手、扩展自由——那它不需要“一统天下”,也能成为企业数字化转型中的坚实底座。

我是怎么把单 Tool Calling 升级成多 Tool Runtime 的

作者 倾颜
2026年3月29日 12:48

本文对应项目版本:v0.0.6

v0.0.5 里,我已经把项目的单 Tool Calling 闭环跑通了:模型能发起 tool_calls,服务端能校验参数、执行工具,前端也能把 reasoning / tool / text 三类内容分开渲染。

但我很快意识到,单 Tool 能跑通,并不意味着系统已经具备了“多能力扩展”的基础。

v0.0.6 我真正想解决的问题不是“再接两个小功能”,而是把当前项目从单 Tool 验证版升级成一个可扩展的多 Tool Runtime

  • 服务端不再只围着一个工具转
  • 新增工具不需要继续把主流程越写越重
  • 前端能稳定展示不同类型的工具调用
  • 多轮上下文不至于随着能力变多而越来越失控

这篇文章就记录一下,这一版我是怎么把 calculator 扩展成 calculator + datetime + text-transform,以及在这个过程中做了哪些设计取舍、踩了哪些坑。

主界面图.png

为什么 v0.0.6 不直接做 Agent

这版最开始其实也有一个很自然的诱惑:既然已经有 Tool Calling 了,是不是下一步应该直接做 Agent、Skill、MCP?

最后我没有这么做,原因很简单:

  • v0.0.5 验证的是“单 Tool 可行”
  • v0.0.6 更值得验证的是“多 Tool Runtime 能不能站住”
  • 如果这时直接进入 Agent Loop、Skill 编排或者 MCP 接入,问题会一下子混在一起

所以我给 v0.0.6 定的边界非常明确:

  1. 只做多 Tool Runtime
  2. 只接入 3 个工具
  3. 不引入新的重型运行时
  4. 优先把注册、校验、执行、展示和上下文管理收稳

也就是说,这版的重点不是“能力平台化”,而是“运行时工程化”。

这版具体做了什么

先把本版范围说清楚。

本次接入的 3 个工具分别是:

  • calculator
  • datetime
  • text-transform

它们分别代表了三类不同能力:

  • calculator:确定性数值计算
  • datetime:确定性时间与日期处理
  • text-transform:结构化文本转换与提取

配套完成的核心能力还有:

  • Tool Registry 重构
  • 前端多 Tool 卡片展示
  • 最近 N=8 轮上下文窗口
  • 一轮回归测试清单与结果记录

这几个点加在一起,才构成了这一版真正的主题:

v0.0.6 的重点不是多了两个 Tool,而是项目第一次具备了可继续生长的多 Tool Runtime 骨架。

总体架构:先稳 Runtime,再谈上层能力

这一版的主链路还是延续前面的设计,只是运行时从“单 Tool”升级成了“多 Tool”。

用户输入
  -> 前端页面
    -> /api/chat
      -> chat-service
        -> Tool Registry
        -> LangChain ChatOllama
        -> tool calling / tool execution
          -> NDJSON stream
            -> useChatStream
              -> reasoning / tool / text 展示

这个架构里,我刻意保留了几件事:

  • 继续用 LangChain.js + Ollama
  • 继续保留自定义 NDJSON 协议
  • 继续保留 useChatStream
  • 继续保留 Markdown + typed parts + Streamdown

原因是这版的关键不是推翻已有结构,而是在已有结构里把 Runtime 层做扎实。

多 Tool 真正难的不是“接入”,而是 Runtime 设计

接一个 Tool 和管理多个 Tool,完全不是一回事。

单 Tool 时,很多问题都还不明显:

  • 工具定义写在一个文件里没关系
  • 主流程里偶尔写一点工具特判也能接受
  • 前端只展示一种 Tool 卡片也还说得过去

但工具一旦从 1 变成 3,问题就会立刻变得具体:

  • 工具怎么注册?
  • 每个工具的 schema 放哪?
  • 参数归一化由谁负责?
  • 不同工具的展示信息从哪来?
  • 错误路径怎么统一处理?
  • 新增一个 Tool 时,主运行时能不能尽量不改?

这也是为什么我把这一版最核心的升级点放在了 Tool Registry 上。

Tool Registry:这版最关键的工程升级

如果只看表面,这版像是在“新增两个 Tool”;但从工程角度看,更重要的是我先把 Tool 组织方式重构了。

我最后采用的是一种轻插件化思路:

  • 每个 Tool 都是独立能力单元
  • 每个 Tool 自己携带 schema、归一化逻辑、展示配置和执行逻辑
  • Runtime 只依赖统一接口,不依赖具体 Tool 的内部细节

这并不是重型插件系统,不涉及动态安装、插件市场或热插拔。它更像是:

Tool 是插件单元,Registry 是插件容器,Runtime 是插件调度层。

关键代码:统一的 Tool 定义接口

这段代码解决的问题是:让不同类型的工具,都能被 Runtime 用同一种方式注册和调度。

export interface ChatToolDefinition<TArgs = unknown> {
    name: string
    tool: StructuredToolInterface
    schema: ZodType<TArgs>
    normalizeArgs?: (args: unknown) => unknown
    formatInput?: (args: TArgs) => string
    formatOutput?: (result: unknown) => string
    getDisplayConfig?: (args: TArgs) => ToolDisplayConfig
    resultIsAuthoritative?: boolean
    isAvailable?: () => boolean
}

这里我最在意的是 4 个字段:

  • schema:决定这个 Tool 的输入边界
  • normalizeArgs:把模型生成的参数做轻量归一化
  • getDisplayConfig:给前端 Tool 卡片提供统一展示信息
  • resultIsAuthoritative:标记某个工具的结果是否应该被视为高优先级事实来源

也就是说,Registry 不是“把几个 Tool 放进数组里”这么简单,而是在为 Runtime 建立一个足够稳定的契约层。

关键代码:Registry 聚合入口

这段代码解决的问题是:新增 Tool 时,主运行时尽量不需要改。

import { calculatorToolDefinition } from './calculator-tool'
import { datetimeToolDefinition } from './datetime-tool'
import { createChatToolRegistry, type ChatToolDefinition } from './registry'
import { textTransformToolDefinition } from './text-transform-tool'

const chatToolDefinitions: ChatToolDefinition[] = [
    calculatorToolDefinition,
    datetimeToolDefinition,
    textTransformToolDefinition,
]

export const chatToolRegistry = createChatToolRegistry(chatToolDefinitions)

这个设计带来的直接收益是:

  • 新增 Tool 原则上只需要“新增文件 + 注册”
  • chat-service 不再需要知道每个 Tool 的内部细节
  • 后续往 Skill / MCP / Agent 方向演进时,也有一个比较清晰的能力层基础

为什么是 calculator、datetime、text-transform

这 3 个工具并不是随便挑的。

calculator:确定性数值工具

calculator 延续自 v0.0.5,它的价值不是“做个计算器”,而是作为多 Tool Runtime 里的基准工具

  • 输入输出边界非常清楚
  • 结果是确定性的
  • 很适合验证 tool_calls -> schema -> execution -> tool result 这条链路

同时,它也让我确认了一件事:

Tool 结果正确,不代表最终回答一定正确。

因为即使 calculator 算对了,模型在第二阶段组织答案时,仍然有可能把内容写歪。所以这类确定性工具,最终还需要“结果优先”策略兜底。

datetime:确定性时间工具

datetime 是这版最有代表性的新增 Tool。

它覆盖的能力包括:

  • 当前时间
  • 日期加减
  • 星期判断

之所以选它,是因为这类问题非常真实,又特别容易暴露 Tool Calling 的稳定性问题。比如:

  • “现在是什么时候”
  • “明天是星期几”
  • “后天是几号”

这类问题本质上都应该优先走工具,但实际运行里,模型有时会自己脑补推断,甚至说“我无法获取当前日期”。这也让我更清楚地看到:

Prompt 能提高命中率,但不能替代 Runtime 兜底。

text-transform:文本转换工具

text-transform 是这一版里我很喜欢的一个选择。

它不是继续做“闲聊能力”,而是验证另一类 Tool 设计方式:一个 Tool 下有多个 action。

我给它收的第一版 action 是:

  • markdown-to-text
  • extract-links
  • extract-code-blocks
  • json-pretty

它的价值在于:

  • 让 Runtime 不再只面向“单功能工具”
  • 提前验证“一 Tool 多 action”的 schema 设计
  • 为后续 Skill 提供更自然的基础能力组件

服务端 Runtime:重点不是能不能调 Tool,而是能不能稳定调对 Tool

多 Tool 之后,chat-service 的职责就不只是“把模型流透给前端”了。

它现在要负责:

  • 创建统一模型配置
  • 基于当前可用工具集合挂载 Tool 能力
  • 处理 planning / retry / final 三段生成
  • 校验 tool_calls
  • 执行 Tool
  • 输出 tool-start / tool-end / tool-error
  • 决定哪些 Tool 结果具有更高优先级

关键代码:统一模型接入层

这段代码解决的问题是:Runtime 不再按问题类型人工分流,而是按当前可用工具集合决定是否挂载 Tool 能力。

function createBaseModel(request: ChatRequest, deps: ChatServiceDependencies) {
    return new ChatOllama({
        model: request.options?.model ?? deps.defaultModel,
        baseUrl: deps.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434',
        temperature: request.options?.temperature ?? 0.3,
        numPredict: request.options?.maxTokens,
        think: request.options?.enableReasoning,
        streaming: true,
    })
}

const baseModel = createBaseModel(request, deps)
const activeTools = chatToolRegistry.listActive()
const toolBoundModel =
    activeTools.length > 0 ? baseModel.bindTools(activeTools.map(toolDefinition => toolDefinition.tool)) : null

这个判断的价值在于:

  • 它不是“普通模型”和“工具模型”两套业务分叉
  • 而是一个基础模型接入层,在运行时决定是否挂工具能力

这比继续往 chat-service 里堆问题类型判断,要干净得多。

关键代码:显式校验 tool call 参数

这段代码解决的问题是:模型输出并不可信,tool call 参数不能直接执行。

const normalizedArgs = toolDefinition.normalizeArgs
    ? toolDefinition.normalizeArgs(toolCall.args)
    : toolCall.args

const parsedArgs = toolDefinition.schema.safeParse(normalizedArgs)

if (!parsedArgs.success) {
    toolErrors.push({
        id: toolCall.id,
        toolName: toolCall.name,
        input: formatToolInput({
            ...toolCall,
            args: normalizedArgs,
        }),
        message: createToolValidationErrorMessage(toolCall, parsedArgs.error),
    })
    continue
}

validatedToolCalls.push({
    ...toolCall,
    args: parsedArgs.data,
})

这一步很关键,因为它把 Tool Calling 从“模型说了算”拉回到了“运行时有边界”。

关键代码:非法 tool call 不再静默 finish

这段代码解决的问题是:当模型生成的全是非法 tool_call 时,前端至少要收到明确的错误,而不是无声结束。

if (toolCalls.length === 0) {
    writeToolValidationErrors(validationResult.toolErrors, writeChunk)
    writeChunk({
        type: 'finish',
    })
    return
}

这是这一版回归测试里修掉的一个很重要的问题。之前如果模型只给出非法 tool_call,服务端会直接 finish,前端看起来像“只思考、不回答、也不报错”,排查体验非常差。

前端:现在渲染的不是字符串,而是工具事件流

进入多 Tool 之后,前端最大的变化不是“多了几个卡片”,而是消费内容的方式变了。

useChatStream 现在处理的是一串结构化事件:

  • start
  • reasoning-*
  • tool-*
  • text-*
  • finish
  • error

这意味着前端不再只是拼接一段字符串,而是在消费整个运行时状态变化。

关键代码:按 chunk 消费 NDJSON 流

这段代码解决的问题是:把服务端输出的结构化 NDJSON 流稳定转成前端状态更新。

async function consumeNdjsonStream(
    stream: ReadableStream<Uint8Array>,
    onChunk: (chunk: ChatStreamChunk) => void
) {
    const reader = stream.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
        const { done, value } = await reader.read()

        if (done) {
            break
        }

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
            const trimmedLine = line.trim()

            if (!trimmedLine) {
                continue
            }

            const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(trimmedLine))

            if (!parsedChunk.success) {
                throw new Error('Invalid chat stream chunk.')
            }

            onChunk(parsedChunk.data)
        }
    }
}

关键代码:最近 N 轮上下文窗口

这段代码解决的问题是:前端保留完整聊天记录,但只把最近窗口回传给模型,减少历史噪音。

const MAX_CONTEXT_TURNS = 8

function getRecentContextWindow(messages: MindMessage[]): MindMessage[] {
    const systemMessages = messages.filter(message => message.role === 'system')
    const conversationalMessages = messages.filter(message => message.role !== 'system')

    const recentMessages: MindMessage[] = []
    let userTurnCount = 0

    for (let index = conversationalMessages.length - 1; index >= 0; index -= 1) {
        const message = conversationalMessages[index]
        recentMessages.unshift(message)

        if (message.role === 'user') {
            userTurnCount += 1

            if (userTurnCount >= MAX_CONTEXT_TURNS) {
                break
            }
        }
    }

    return [...systemMessages, ...recentMessages]
}

这里我没有一上来做摘要记忆,而是先固定 N = 8。原因很简单:

  • 实现简单
  • 行为稳定
  • 容易测试
  • 方便版本对比

对当前阶段来说,这是一个非常划算的工程折中。

took-think1.png

这版最值得记录的几个坑

这篇文章如果只写“最终方案”,其实会显得太平。v0.0.6 真正有价值的地方,恰恰在于它暴露了多 Tool Runtime 里的很多真实问题。

1. 多 Tool 不等于模型会稳定选 Tool

这版最明显的现象是:

  • calculator 相对比较稳
  • datetime 在“当前时间 / 相对日期”上并不稳定
  • text-transform 在非法 JSON 边界输入上也会绕开 Tool

这说明一个很现实的问题:

Tool 变多以后,系统的难点会从“能不能调 Tool”,转成“能不能稳定地调对 Tool”。

2. Prompt 很重要,但真的有上限

这一版我对 prompt 做了不少收紧,尤其是时间类问题:

  • 明确当前时间要走 datetime
  • 明确相对日期要优先调工具
  • 明确不能说“我无法获取当前日期”

这些约束确实有帮助,但实践下来我也越来越确定:

Prompt 可以提高命中率,但不能替代 Runtime。

尤其是:

  • 相对日期
  • 非法 JSON

这类边界问题,一旦只靠 prompt,收益会越来越接近上限。

3. 非法 tool call 的静默 finish

这个问题前面已经提过,但我觉得很值得单独记录。

因为它特别像真实工程里那种“功能看起来没挂,但体验非常差”的问题:

  • 服务端没有崩
  • 前端也没有明显异常
  • 但整条对话就像凭空断掉了一样

这种问题不把 Runtime 的错误链路补清楚,后面会非常难调。

4. 版本边界控制本身也是设计能力

这一版做完以后,我反而更确信一件事:

知道什么时候不做,比知道还能做什么更重要。

因为如果这版继续往下扩:

  • Agent Loop
  • Skill Runtime
  • MCP 接入

那整篇文章和整版工程都会失焦。

回归测试:这版到底稳到了什么程度

这一版我没有只凭“感觉能跑”就结束,而是把回归测试清单和实际回归结果单独整理成了文档。

重点验证了几类场景:

  • 普通问答
  • calculator
  • datetime
  • text-transform
  • 多轮上下文
  • 非法 tool_call
  • 一轮失败后下一轮是否受影响

当前可以认为比较稳定的部分:

  • 普通问答直答
  • calculator
  • text-transform 正常输入路径
  • 非法 tool_call 的服务端错误透传

还没有完全收稳的部分:

  • datetime 的当前时间 / 相对日期问题
  • text-transform(json-pretty) 的非法 JSON 边界输入

这也恰好说明了这版的真实状态:

多 Tool Runtime 骨架已经成型,但某些工具边界还在逼近“单靠 Prompt 不够”的上限。

当前版本已经完成什么,还没有完成什么

如果给 v0.0.6 一个比较准确的状态定义,我会说:

方案已经基本实现完成,版本进入收口和打磨阶段。

已完成

  • 多 Tool Runtime
  • Tool Registry
  • calculator
  • datetime
  • text-transform
  • 前端多 Tool 展示
  • 最近 N=8 轮上下文窗口
  • 回归测试清单与结果记录

还没做

  • Skill 系统
  • MCP 接入
  • Agent Loop
  • 长期记忆
  • 并行工具调度

这不是缺陷,而是我刻意保留的版本边界。

下一步路线:从 Runtime 走向更高层能力

这版做完之后,后面的演进路线已经比之前清楚很多了。

我现在更倾向于把后续能力理解成一条逐步演进链:

  • Tool:原子能力
  • Skill:能力模板
  • MCP:外部能力接入标准
  • Agent:调度这些能力完成任务的运行时

从这个角度看,v0.0.6 的价值并不只是多了两个 Tool,而是它第一次把这条演进链的底座打得比较像样了。

最后总结

这版真正完成的,不是“多接了两个工具”,而是让项目第一次具备了可继续演进的多 Tool Runtime 骨架。

它解决的是一组更底层、也更长期的问题:

  • Tool 怎么注册
  • Tool 怎么校验
  • Tool 怎么执行
  • Tool 怎么展示
  • 多轮上下文怎么控制

而这些问题一旦理顺,后面无论是继续扩 Tool,还是往 Skill / MCP / Agent 走,都会自然很多。

项目地址

GitHub:[github.com/HWYD/ai-min…]

如果这篇文章或这个项目对你有帮助,欢迎点个 Star 支持一下。
后续我也会继续按版本节奏,把它往 Skill、MCP、Agent 的方向一点点推进下去。

Superpowers:给 AI 编程 Agent 装上"工程化超能力"

作者 清汤饺子
2026年3月29日 12:43

Hi~大家好呀,我是清汤饺子。 前几天让 Claude Code 帮我写个小功能,它噼里啪啦一顿输出,代码倒是挺像那么回事。一跑,报错 40 个。

我盯着屏幕愣了三秒,然后开始一个个手动修。

事后复盘,问题不在 AI 写的代码烂,而在于——它太有热情了。拿到需求就开干,根本不问我"你想解决什么问题"、"这个场景下最优解是什么"。

这感觉就像招了一个「执行力超强但完全没有工程纪律」的 junior。

然后我发现了 Superpowers。

01 解决什么问题

AI Coding Agent 最大的通病,懂的都懂:

  • 拿到需求就开干:不等你确认,先肝为敬
  • 不写测试:代码写完自己都不知道写了啥
  • 代码像开盲盒:这次好使,下次不知道哪个版本就崩了

人类工程师有 TDD、有 code review、有设计评审,有一整套工程纪律来约束自己。但 AI Agent 呢?它只管输出,不管后果。

Superpowers 就是干这个的——给 AI Agent 装上一组技能卡,让它学会工程化的工作流

不是让它更聪明,是让它更有章法。

02 Superpowers 是什么

这是 Jesse Vincent(GitHub @obra)做的一个开源项目,全称是 Superpowers — An agentic skills framework & software development methodology

翻译成人话:一套给 AI 编程 Agent 用的技能框架

它不是让你用更厉害的模型,而是让你的 AI Agent 具备一套工程化思维:

  • 写代码前先做设计评审
  • 先写测试再写实现
  • 任务拆解到 2-5 分钟一个
  • 子 Agent 并行执行 + 两阶段 review

支持 Claude Code、Cursor、Codex、OpenCode 和 Gemini,主流 AI 编程工具都能用。

03 这工作流是怎么跑起来的

第一步:brainstorming —— 先别写代码,灵魂拷问一下

Superpowers 的第一条技能叫 brainstorming,触发时机是「写代码之前」。

当 AI 看到你要做新功能,它不会直接开干,而是反过来问你:

"你到底想解决什么问题?" "这个场景下有哪些边界情况?" "你觉得最优解是什么?"

我第一次用它做设计,它连着问了我 6 个问题才肯动笔。那感觉……像找了个 senior 在给我做 design review。

Socratic 追问,让 AI 先理解需求再动手。这治好了 AI "拿到需求就肝" 的毛病。

第二步:writing-plans —— 任务拆解到 2-5 分钟

需求确认之后,进入 writing-plans 技能。

AI 会把整个功能拆成若干小任务,每个任务:

  • 精确到文件路径
  • 有完整的代码内容
  • 有验收标准

更关键的是:每个任务 2-5 分钟就能跑完

以前我让 AI 写整个功能,它容易迷失在中途。现在它把活儿拆成「傻瓜式操作手册」,就像给一个「执行力强但没耐心」的 junior 写了一份 2 分钟就能完成的小任务清单。

第三步:subagent-driven-development —— 子 Agent 并行跑

计划就绪,主 Agent 调度 subagent-driven-development 技能。

它的核心是:

  1. 子 Agent 并行执行:每个任务交给独立的子 Agent 处理
  2. 两阶段 review:先检查规格是否合规,再检查代码质量
  3. 连续运行能力:实测 Claude 可以连续跑 2 小时不用管

简单说就是:你当老板,AI 们当工人。主 Agent 包工头负责分配任务、监督进度、质量把关。

第四步:TDD 红绿重构 —— 先写测试这道坎

这是我觉得最有价值的部分:test-driven-development

核心流程就三步:

  1. RED:写一个注定失败的测试
  2. GREEN:写最少的代码让测试通过
  3. REFACTOR:重构优化

重点是:必须先写测试,再写实现,测试前的代码直接删掉

这治好了 AI "写完代码懒得测" 的毛病。以前我让 AI 写功能,它输出完就完事,根本不管测试。现在它被强制绑上了 TDD 的战车。

第五步:收尾工作 —— finishing-a-development-branch

任务全部完成后,finishing-a-development-branch 技能接管:

  • 验证所有测试通过
  • 给出四个选项:merge / PR / 保留 / 丢弃
  • 自动清理 worktree

不需要你手动去处理分支清理,AI 会把收尾工作做完。

04 技能全景图

技能 触发时机 作用
brainstorming 写代码前 需求澄清,Socratic 追问
writing-plans 设计批准后 任务拆分,2-5min/任务
using-git-worktrees 设计批准后 创建独立分支,验证干净测试基线
verification-before-completion 调试完成后 验证问题真的修好了
subagent-driven-development 计划就绪 子 Agent 并行执行 + 两阶段 review
test-driven-development 实现中 强制红绿重构
systematic-debugging 调试时 4 阶段根因分析
requesting-code-review 任务间 按严重性报告问题
finishing-a-development-branch 任务完成 收尾 + 分支清理

这套技能的精妙之处在于:触发完全自动。你不需要手动调用,AI 会根据当前任务状态自动匹配技能。

就像给 AI 装了一堆「工程化本能」,遇到对应场景自动触发。

05 怎么装上

各平台安装方法:

Claude Code

# 方式一:官方 Claude 插件市场(推荐)
/plugin install superpowers@claude-plugins-official

# 方式二:社区 marketplace(需要先注册)
/plugin marketplace add obra/superpowers-marketplace
/plugin install superpowers@superpowers-marketplace

Cursor

# 在 Agent chat 中
/add-plugin superpowers

Codex

Fetch and follow instructions from https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/.codex/INSTALL.md

安装大约 5 分钟,配上之后的感觉像是——给 AI 做了一个完整的入职培训。

06 真实感受

惊喜时刻

  • 项目节奏完全变了。以前我追着 AI 跑,现在是 AI 追着任务跑
  • Claude 真的能连续跑 2 小时不出岔子
  • TDD 闭环治好了我懒得写测试的毛病

崩溃时刻

  • 第一次用的时候它问太多问题(brainstorming 阶段),差点想卸载
  • 配置比想象中复杂,需要花时间理解每个技能的触发逻辑

适合的人

  • 有一定经验的开发者,懂 TDD、懂工程化的人用起来如虎添翼
  • 团队协作场景,AI 能承接更多的工程纪律

不适合的人

  • 纯新手可能觉得被束缚,不知道为什么要这么做
  • 小项目不值得折腾,简单功能直接让 AI 写反而更快

07 本质是什么

用了一圈下来,我觉得 Superpowers 的本质是:

不是让 AI 替代你,是让它成为更有章法的搭档。

它不是在提升 AI 的智商,而是在约束 AI 的行为——让它像人类工程师一样思考、像人类工程师一样工作。

以前我把活儿交给 AI,总是提心吊胆,不知道它会整出什么幺蛾子。现在有了 Superpowers 的工程化约束,我更愿意把任务交给 AI 了。

因为它不会再半夜给我埋雷。

08 技术原理

看完了 GitHub 仓库之后,我发现 Superpowers 的实现比"配置文件合集"要精妙得多。

SKILL.md:技能即文档

每个技能都是一个 .md 文件(Markdown),放在 skills/ 目录下。文件格式包含两部分:

Frontmatter(YAML 元信息)

---
name: brainstorming
description: Use when [condition] - [what it does]
---

正文内容:技能的详细指令,告诉 AI 在什么场景下怎么做。

关键是 description 字段——这是 AI 自动发现和触发技能的依据。Codex/Claude Code 在每次任务执行前,会扫描 ~/.agents/skills/ 目录,根据 description 匹配当前上下文,自动激活对应技能。整个过程不需要你手动调用。

7 步接力:上游输出驱动下游输入

每一步的输出成为下一步的输入:

  • brainstorming 产出设计文档(human-approved)
  • using-git-worktrees 创建隔离分支环境
  • writing-plans 把设计拆成任务清单
  • subagent-driven-development 按任务执行 + 两阶段 review
  • test-driven-development 强制 TDD
  • requesting-code-review 任务间按严重性报告问题
  • finishing-a-development-branch 收尾

两阶段 Review:规格合规 → 代码质量

每个子 Agent 完成任务后,经历两关:

  1. 规格合规性审查:任务有没有按 plan 执行?有没有超出范围?
  2. 代码质量审查:代码本身写得怎么样?有没有明显 bug 或坏味道?

两关都过,才进入下一个任务。这治好了 AI "做多了或做歪了" 的问题。

TDD 强制闭环

test-driven-development 的核心规则:

  • RED:AI 必须先写一个注定失败的测试
  • GREEN:然后写最少的代码让测试通过
  • REFACTOR:最后重构优化

最狠的一条:测试写出来之前的代码直接删掉。AI 没有"先写实现后补测试"的选项。

哲学层:Process over guessing

README 里 Jesse Vincent 写了四条原则:

  • Test-Driven Development — 先写测试,永远
  • Systematic over ad-hoc — 系统化流程 > 猜测
  • Complexity reduction — 简单性是首要目标
  • Evidence over claims — 用验证说话,不要只靠感觉

本质就是:不要相信 AI 的直觉,要相信工程纪律

GitHub 仓库:github.com/obra/superp…


写在最后

Superpowers 这套技能框架,解决的不是 AI 能力不足的问题,而是 AI 行为不可控的问题。

如果你也在用 AI Coding Agent,感觉它"太热情但不靠谱",建议试试这套方法论。

当然,它不是银弹。工程纪律是给有工程经验的人用的,如果你本身对 TDD、代码审查这些概念不熟悉,Superpowers 可能会让你更困惑。

核心问题是:你愿不愿意花时间教会 AI 按你的方式工作?

这个问题没有标准答案,取决于你的项目规模和团队情况。


你在用 AI 编程工具吗?有什么"AI 疯狂输出但最后还是我来收拾烂摊子"的经历吗?欢迎在评论区聊聊,看看大家都有什么奇葩故事。

如果觉得有帮助,点个赞收藏一下,我会更有动力更新下一期。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

Tauri 2 Linux 上 asset://localhost 访问返回 403 避坑指南

作者 ssshooter
2026年3月28日 12:27

很多人在 Tauri v2(尤其是 Linux 系统)中使用 convertFileSrc()asset://localhost 协议加载本地图片、视频、音频等资源时,经常遇到 403 Forbidden 错误。Windows/macOS 可能正常,Linux 却直接翻车。

本文把整个坑的来龙去脉、根本原因、glob 匹配规则彻底讲清楚,并给出最稳的配置方案,帮助大家一次性避坑。

一、问题现象

  • 使用 convertFileSrc(fullPath) 生成的 URL 在 <img><video><audio> 等标签中加载失败
  • 浏览器控制台报 403
  • 终端(Rust 侧)日志提示类似:
    asset protocol not configured to allow the path: /home/user/.local/share/xxx/xxx.png
    
  • 尤其容易出现在 隐藏目录(以 . 开头的目录)下:.local/share.cache.config

二、根本原因:Tauri 的 Glob Scope + Linux 隐藏目录规则

Tauri v2 的 assetProtocol.scope 使用的是 Rust globset 库实现的 glob 模式来做安全校验。只有路径匹配 scope 里的 glob,才允许浏览器通过 asset 协议访问。

最坑的一点在于,Linux(Unix-like 系统)下:

通配符 *?**默认不会匹配以 . 开头的路径(dotfiles / dotdirs),除非你在 glob 模式里字面写出 .

所以即使你写了最宽松的 "**/*",它也进不了 .local.cache 等隐藏目录,导致 403。

这不是 bug,而是 Tauri 为了安全故意设计的(和 Linux shell 的 ls * 默认不显示隐藏文件一样)。

三、Glob 模式最容易搞混的两个写法:**/ vs **/*

glob 写法 含义 能匹配什么 在 assetProtocol.scope 里的实际效果 推荐程度
**/* 递归匹配所有文件 文件(如 a.pngsub/b.mp4 ✅ 强烈推荐 ★★★★★
**/ 递归匹配所有目录 纯目录路径(如 images/sub/ ❌ 几乎没用(scope 要的是文件路径) ★☆☆☆☆

一句话总结

  • **/* = “递归所有文件”(你 99% 的情况都需要这个)
  • **/ = “递归所有目录”(基本不要单独写在 scope 里)

正确写法是 你的路径/**/* 或直接 **/*

四、正确配置(一步到位)

1. 主配置(推荐同时加 Linux 专属配置)

src-tauri/tauri.conf.json(全局):

{
  "app": {
    "security": {
      "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost; audio-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline';",
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**",
          "$HOME/**"
        ]
      }
    }
  }
}

src-tauri/tauri.linux.conf.json(Linux 专属,强烈建议):

{
  "app": {
    "security": {
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**"
        ]
      }
    }
  }
}

这样 Windows/macOS 不会被多余的 scope 影响。

2. 代码侧使用(不变)

import { convertFileSrc } from '@tauri-apps/api/core';

const assetUrl = convertFileSrc(absoluteFilePath);

五、操作流程

  1. 按上面修改配置文件
  2. (推荐)cargo clean
  3. pnpm tauri dev(或 npm run tauri dev)测试
  4. 还是 403?看终端日志,把报错里提示的路径对应的 glob 补进去

六、额外避坑小贴士

  • 用 Tauri 内置变量 $CACHE$CONFIG 最香,自动处理平台差异
  • 如果是用户通过 dialog.open() 选择的路径,Tauri 会自动扩展 scope,但持久化路径仍需写进配置
  • 打包进 bundle 的资源不需要 assetProtocol,走 frontendDist 即可
  • Rust 版本建议 ≥ 1.77,Tauri CLI 保持最新

总结
Tauri 2 的 asset 403 坑,99% 是因为 Linux 下 glob 默认不匹配 . 开头的隐藏目录。只要把 **/* + **/.local/share/**/* + $CACHE/** 写全,问题基本秒解。

把这篇配置直接复制到你的项目里,基本不会再踩这个坑了。

希望这篇文章能帮到更多 Tauri 开发者少走弯路!
如果你还有其他 Tauri v2 的奇葩问题,欢迎继续留言~

不同阶段的 iOS 应用混淆工具怎么组合使用,源码混淆、IPA混淆

2026年3月27日 18:00

如果把 iOS 应用的混淆只理解成改类名,就会低估这个问题。实际项目里,信息暴露点分散在多个阶段,源码命名、编译产物、资源目录、甚至签名后的 IPA 结构。只用一个工具,很难覆盖完整路径。

这篇文章沿着构建流程往下走,看看每个阶段可以做什么处理,以及不同工具如何拼在一起使用。

在源码阶段先做可控改名

项目还在开发阶段时,可以先处理一部分明显暴露语义的命名,例如:

class VipSubscriptionManager
class PaymentOrderController

如果直接进入编译阶段,这些名称会被带入二进制。

可以通过脚本做一轮批量替换,例如:

  • 使用 Python 脚本扫描类名
  • 生成映射表
  • 替换为无语义名称

这一步的特点是:

  • 控制粒度高
  • 需要改动工程
  • 对团队规范有要求

如果项目已经稳定,这一步不一定适合继续做。

利用 Xcode 构建参数裁剪符号

进入构建阶段,可以先减少一部分信息暴露。

在 Release 配置中:

Strip Debug Symbols = YES
Dead Code Stripping = YES

构建后检查:

strings AppBinary | head

输出会比 Debug 包干净,但核心类名仍然存在。

这一阶段主要是“减少冗余”,不是混淆。

用命令行工具检查当前暴露程度

在进入下一步之前,可以用工具做一次快速判断:

strings AppBinary | grep ViewController

如果输出类似:

LoginViewController
ProfileViewController

说明结构仍然清晰,也可以用:

  • class-dump 查看接口
  • Hopper 查看符号表

这一步的目的是明确需要处理的范围。


在 IPA 层做统一混淆

当项目已经打包成 IPA 后,可以用专门的 iOS 应用混淆工具进行处理。

这里引入 Ipa Guard,它的处理方式不是修改源码,而是直接解析 Mach-O 文件并替换符号。

操作流程:

  1. 打开工具,加载 IPA
  2. 进入代码模块
  3. 选择需要处理的内容

可以看到:

OC 类
Swift 类
OC 方法
Swift 方法

代码混淆

在实际项目中,我们会筛选:

UserManager
PaymentService
VipController

执行混淆后:

UserManager → a82k3

再次用 strings 查看,原名称不会再出现。


资源文件处理不要忽略

很多人只处理代码,但资源同样是入口。

例如:

config/payment.json
assets/vip_banner.png

这些文件名称直接说明业务。

Ipa Guard 的资源模块可以:

  • 批量改名
  • 更新引用路径

处理后:

payment.json → x92ks.json
vip_banner.png → a8d3k.png

重命名


引入前端工具处理 JS / H5

如果项目中有 WebView 或 H5 页面,仅改名不够。

可以在构建阶段执行:

terser main.js -o main.min.js

或:

uglifyjs page.js -o page.min.js

压缩后再交给 IPA 混淆工具处理文件名。

这样组合后:

  • 内容不可读
  • 文件名无语义

修改资源指纹用于打散特征

当多个应用使用相同资源时,文件内容会成为识别依据。

Ipa Guard 支持修改资源 MD5:

md5 banner.png

处理前后结果不同。

这一层不影响功能,但会改变资源特征。 md5


清理调试信息

很多项目在 Release 包中仍然保留日志。

可以检查:

strings AppBinary | grep NSLog

如果输出较多,可以在 IPA 处理阶段删除。

Ipa Guard 支持清理调试信息,使二进制更简洁。


签名工具补上最后一步

所有修改完成后,必须重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置签名参数。

安装到设备后,验证:

  • 页面是否正常
  • 动态调用是否有效
  • 资源是否加载

重签名


iOS 应用混淆不是某个工具的功能,而是一整条流程。源码阶段、构建阶段、IPA 阶段,各自能做的事情不同。把这些步骤串起来,比单独使用某一个工具更有效。

参考链接:ipaguard.com/blog/161

01. Node.js 运行时

作者 打酱油的D
2026年3月27日 11:27

01. Node.js 运行时

先别急着背框架。后端第一步,是搞懂 Node.js 为什么能持续处理请求,以及什么代码会把服务拖垮。

Node.js 的核心不是“会写异步”,而是理解这三个东西怎么配合:

  • V8:执行 JavaScript
  • libuv:负责事件循环、线程池、I/O 调度
  • Node 标准库:提供 httpfsstreamnet 等能力

核心认知

  • Node.js 不是“把浏览器里的 JavaScript 搬到后端”。
  • JavaScript 执行通常是单线程的,但 I/O 能并发推进,这也是 Node.js 适合做网络服务的原因。
  • 服务端进程会长期运行,所以稳定性、资源释放和错误处理比页面渲染更重要。

一条请求在 Node.js 里经历了什么

  1. 客户端建立 TCP 连接,发来 HTTP 请求。
  2. Node.js 的网络层收到请求,把它包装成 req / res 对象。
  3. 事件循环调度对应的回调或中间件。
  4. 你的代码可能去查数据库、读文件、访问 Redis。
  5. I/O 完成后,回调被重新放回事件循环继续执行。
  6. 最终写回响应,连接保持或关闭。

要点只有一句:Node.js 可以同时管理很多 I/O,但不能容忍你长时间霸占主线程。

必懂 4 件事

1. Event Loop
  • Node.js 不是一次只处理一个请求,而是依靠事件循环调度大量异步任务。
  • 只要你写了长时间的同步阻塞代码,整个进程都会被卡住。
  • 所以要警惕同步文件操作、超大 JSON 解析、死循环、重 CPU 计算。

最少要知道这些阶段的名字:

  • timers:执行 setTimeout / setInterval
  • pending callbacks
  • poll:等待和处理大部分 I/O 回调
  • check:执行 setImmediate
  • close callbacks

还要额外记住两个“优先队列”:

  • process.nextTick
  • Promise microtask

process.nextTick 和 Promise microtask 都会在阶段切换前优先清空,所以滥用也会饿死 I/O。

console.log('A');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

console.log('B');

典型输出通常是:

A
B
nextTick
promise
timeout / immediate

你不需要死记每次谁先谁后,但必须知道:nextTick 和 Promise 回调优先级高于下一轮普通 I/O。

2. 什么叫阻塞主线程

下面这类代码在浏览器里也许只是卡一下页面,在服务端会直接拖慢所有用户请求:

import express from 'express';

const app = express();

app.get('/block', (_req, res) => {
  let total = 0;

  for (let i = 0; i < 1_000_000_000; i++) {
    total += i;
  }

  res.json({ total });
});

这段代码的坏处不是“写法丑”,而是它在循环期间完全占住了主线程。其他请求即使只是查一个轻量接口,也得排队。

遇到重 CPU 任务时,常见做法有三种:

  • 改算法,减少同步计算量
  • 拆成离线任务或消息队列
  • worker_threads 或独立服务处理计算任务
3. Stream 和 Buffer
  • Buffer 是二进制数据的容器。
  • Stream 是分块处理数据的方式,不必一次性把所有内容读入内存。
  • 文件上传、下载、反向代理、SSE、大模型流式输出都离不开它。

为什么服务端必须重视 Stream:

  • 大文件不能一次性读进内存
  • 上游和下游速度不一致时,需要背压控制
  • 文件、网络、压缩、代理都天然是流式场景

下面是一个标准的下载接口写法:

import express from 'express';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

const app = express();

app.get('/download', async (_req, res, next) => {
  try {
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', 'attachment; filename="report.csv"');

    const fileStream = createReadStream('./files/report.csv');
    await pipeline(fileStream, res);
  } catch (error) {
    next(error);
  }
});

错误写法通常是这样:

const content = await fs.promises.readFile('./files/report.csv');
res.send(content);

文件小时没问题,文件一大、并发一高,内存就会顶上去。

4. 进程与内存
  • 页面卡了可以刷新,服务卡了会影响所有请求。
  • 需要关注内存泄漏、未关闭连接、无限增长的缓存、未处理异常。
  • 最基本的观察项包括:rss、heap、错误日志、请求耗时。

一个最常见的泄漏例子:

import express from 'express';

const app = express();
const leaked: unknown[] = [];

app.get('/leak', (req, res) => {
  leaked.push({
    query: req.query,
    now: Date.now(),
  });

  res.json({ size: leaked.length });
});

只要这个数组不清,进程就会一直涨。真实项目里更隐蔽的版本包括:

  • 全局 Map 缓存从不淘汰
  • 长连接对象没有正确关闭
  • 定时器创建后不清理
  • 每个请求都把大对象挂在全局变量上

可以用最小代码观察内存:

setInterval(() => {
  const memory = process.memoryUsage();

  console.log({
    rssMB: Math.round(memory.rss / 1024 / 1024),
    heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
    externalMB: Math.round(memory.external / 1024 / 1024),
  });
}, 5000);

最小实践

  • 写一个接口,用流返回大文件,而不是一次性读进内存。
  • 故意写一个同步阻塞接口,观察并发请求响应时间变差。
  • 打印 process.memoryUsage(),理解进程内存的变化。

常见误区

  • 误以为“异步 = 多线程”。不是,JavaScript 代码执行仍主要在主线程上。
  • 误以为 Promise.all 越多越快。一次把几千个任务并发打出去,可能先把数据库压垮。
  • 误以为 Node.js 不适合所有重任务。准确说法是:它不适合把重 CPU 任务长期放在主线程。

学会的标准

  • 你能解释为什么 fs.readFileSync 在服务端要慎用。
  • 你知道文件上传为什么不该默认整文件进内存。
  • 你知道 Node.js 的问题不只有“代码慢”,也可能是阻塞、资源泄漏和并发放大。

图文教学,服务端如何发送(钉钉 +飞书 )机器人通知

作者 工边页字
2026年3月27日 10:00

一共就两步,创建自定义机器人,然后拿到请求接口,最后把消息发出去。完事~

飞书和钉钉基本上都是一个套路,很简单的~

我们开始

创建一个钉钉机器人

首先你得有个群聊

image.png

image.png

image.png

image.png

image.png

如果没有发送的文字里没有我们刚刚设定关键字,钉钉接口会返回如下内容

image.png

image.png

钉钉服务端发送代码

接下来只需要对这个接口进行http请求就完事了

我先来演示下效果,然后,会给出node,php,java,go,python五个语言的演示case

image.png

🟩 1. Node.js(ESM版)

import express from "express";
import axios from "axios";

const app = express();
const PORT = 3000;

const DINGTALK_WEBHOOK =
  "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

app.get("/send-dingtalk", async (req, res) => {
  try {
    const payload = {
      msgtype: "text",
      text: {
        content: "Node.js 发送测试",
      },
    };

    const response = await axios.post(DINGTALK_WEBHOOK, payload);

    res.json(response.data);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(PORT, () => {
  console.log(`http://localhost:${PORT}`);
});

🟨 2. Java(Spring Boot)

import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.*;

@RestController
public class DingController {

    private static final String WEBHOOK =
        "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

    @GetMapping("/send-dingtalk")
    public Object send() {
        RestTemplate restTemplate = new RestTemplate();

        Map<String, Object> payload = new HashMap<>();
        payload.put("msgtype", "text");

        Map<String, String> text = new HashMap<>();
        text.put("content", "Java 发送测试");

        payload.put("text", text);

        return restTemplate.postForObject(WEBHOOK, payload, String.class);
    }
}

🟪 3. PHP

<?php

$url = "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN";

$data = [
    "msgtype" => "text",
    "text" => [
        "content" => "PHP 发送测试"
    ]
];

$options = [
    "http" => [
        "header"  => "Content-Type: application/json",
        "method"  => "POST",
        "content" => json_encode($data),
    ]
];

$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);

echo $result;

🟦 4. Python

import requests

url = "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN"

payload = {
    "msgtype": "text",
    "text": {
        "content": "Python 发送测试"
    }
}

response = requests.post(url, json=payload)

print(response.json())

🟫 5. Go

package main

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)

func main() {
url := "https://oapi.dingtalk.com/robot/send?access_token=YOUR_DINGTALK_TOKEN"

payload := map[string]interface{}{
"msgtype": "text",
"text": map[string]string{
"content": "Go 发送测试",
},
}

jsonData, _ := json.Marshal(payload)

resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
panic(err)
}
defer resp.Body.Close()

fmt.Println("Status:", resp.Status)
}

创建一个飞书机器人

其实飞书和钉钉的流程大差不差

image.png

image.png

image.png

image.png

image.png

image.png

飞书发送代码

其实设钉钉的流程是一样的,就是吧url换一下,入参结构换一下。为了大家方便,我还是五个语言的case都来一份,要case的可以直接cv过去试试

image.png

1️⃣ Node.js

import axios from "axios";

const FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

const payload = {
  msg_type: "text",
  content: {
    text: "这是给伙计们的测试数据,带了‘测试’两个字"
  }
};

axios.post(FEISHU_WEBHOOK, payload)
  .then(res => console.log(res.data))
  .catch(err => console.error(err));

2️⃣ Python

import requests

FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE"

payload = {
    "msg_type": "text",
    "content": {
        "text": "这是给伙计们的测试数据,带了‘测试’两个字"
    }
}

response = requests.post(FEISHU_WEBHOOK, json=payload)
print(response.json())

3️⃣ Java (使用 HttpClient, Java 11+)

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class FeishuBot {
    public static void main(String[] args) throws Exception {
        String webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

        String json = "{"
                + ""msg_type":"text","
                + ""content":{"text":"这是给伙计们的测试数据,带了‘测试’两个字"}"
                + "}";

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(webhook))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        HttpClient client = HttpClient.newHttpClient();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.body());
    }
}

4️⃣ PHP

<?php
$webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE";

$data = [
    "msg_type" => "text",
    "content" => [
        "text" => "这是给伙计们的测试数据,带了‘测试’两个字"
    ]
];

$options = [
    'http' => [
        'header'  => "Content-Type: application/json\r\n",
        'method'  => 'POST',
        'content' => json_encode($data),
    ],
];

$context  = stream_context_create($options);
$result = file_get_contents($webhook, false, $context);
if ($result === FALSE) { /* 错误处理 */ }

echo $result;

5️⃣ Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

func main() {
    webhook := "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN_HERE"

    payload := map[string]interface{}{
        "msg_type": "text",
        "content": map[string]string{
            "text": "这是给伙计们的测试数据,带了‘测试’两个字",
        },
    }

    b, _ := json.Marshal(payload)
    resp, err := http.Post(webhook, "application/json", bytes.NewBuffer(b))
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)
    fmt.Println(result)
}

最后

如果对你有用的话

点赞收藏吃灰去呀~

❌
❌