普通视图

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

面试官:给 llm 传递上下文,有哪几个身份 role ❓❓❓

作者 Moment
2026年4月29日 18:57

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

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

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

image.png

很多项目在早期都能跑通,到了中后期却开始不稳。最常见的原因不是模型变差,而是上下文结构越来越乱。你把规则、问题、历史、检索结果、工具输出全部堆在一起,短期看起来省事,长期一定会出问题。常见表现有这些:

  • 明明要求输出 JSON,模型还是自由发挥
  • 明明给了检索结果,模型却忽略证据
  • 明明上一轮说清楚了,这一轮又答偏
  • 一加新条件,前面的格式约束就失效
  • 出问题时很难定位是规则错、检索错还是历史污染

问题的核心不在提示词文案,而在上下文分层。role 的价值正是在这里。

role 的本质

role 不是标签装饰,它在告诉模型三件事:

  • 这段内容来自谁
  • 这段内容属于哪一层
  • 这段内容应按什么优先级理解

同一句话放在不同 role,效果会明显不同。比如 请只输出 JSON 放在高优先级规则层通常更稳,塞进用户问题里更容易在复杂场景被冲掉。所以 role 解决的是上下文治理问题,不是接口语法问题。

常见 role 和信息来源

在多数对话接口里,核心角色通常是四类:

  • developer
  • system
  • user
  • assistant

还有一个容易混淆的点,工具返回结果通常不应当当作普通对话角色,而应作为独立证据输入。从工程视角看,一次请求里的上下文来源通常是五层:

  • 规则层,通常来自 systemdeveloper
  • 任务层,来自当前 user
  • 历史层,来自对话历史中的 userassistant
  • 事实层,来自 tool、检索或数据库
  • 生成目标层,定义这一轮最终输出要求

四个核心角色怎么用

developer

developer 是应用开发者写给模型的长期行为约束。它描述这个助手长期应如何工作,而不是本轮要回答什么问题。适合放在这里的内容:

  • 助手定位
  • 默认语言
  • 回答结构
  • 输出格式
  • 工具使用策略
  • 不确定时的处理方式
  • 禁止编造规则
const input = [
  {
    role: "developer",
    content:
      "你是技术讲解助手。默认中文。先给结论再展开。不确定时明确说明,不要编造。",
  },
  {
    role: "user",
    content: "请解释 JWT 和 Session 的区别",
  },
];

system

system 也是高优先级层,但更偏平台级或全局边界。它常用于跨场景都成立的底线规则。适合放在这里的内容:

  • 全局身份边界
  • 合规与安全要求
  • 平台级能力限制
  • 不可突破的红线

很多项目里 developersystem 会有重叠。只要职责清晰,是否拆开都可以。

user

user 承载本轮任务目标,不承载长期规则。它回答的是现在要做什么,而不是系统长期怎么做。常见内容:

  • 当前问题
  • 补充条件
  • 输出偏好
  • 输入材料
const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答准确并保持简洁。",
  },
  {
    role: "user",
    content: "请解释什么是 RAG,并给一个 TypeScript 场景示例",
  },
];

assistant

assistant 是模型历史回复层,作用是保持多轮连续性。它不是规则层,也不是事实仓库。

const input = [
  {
    role: "developer",
    content: "你是前端导师,解释时要循序渐进。",
  },
  {
    role: "user",
    content: "什么是向量数据库",
  },
  {
    role: "assistant",
    content: "向量数据库是为高维向量检索设计的存储与查询系统。",
  },
  {
    role: "user",
    content: "它和传统数据库的区别是什么",
  },
];

assistant 历史不是越多越好。历史过长、重复或噪声过多,会直接拉低后续轮次稳定性。

工具返回到底放哪层

工具结果、检索片段、数据库查询、网页抓取,本质上都是外部证据,不是模型自己说过的话。如果把这些内容伪装成 assistant 历史,会出现三个问题:

  • 语义边界混乱,模型分不清自述和证据
  • 历史层污染,后续轮次越来越难控
  • 调试成本上升,问题定位困难

更稳的策略是:

  • 规则放 systemdeveloper
  • 任务放 user
  • 历史放 assistant
  • 证据放独立事实层

RAGAgent、工作流编排里,这一点几乎是稳定性的分水岭。

一句话说清楚:把规则、任务、历史、外部事实和生成目标分层放置,LLM 的稳定性、可信度和可调试性都会明显提升。

四种高频场景的组织方式

单轮问答

developer + user 即可,结构最轻。

const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答清晰且准确。",
  },
  {
    role: "user",
    content: "请解释什么是 SSE",
  },
];

多轮对话

developer + user 基础上加入必要 assistant 历史,保证上下文连续。

RAG 问答

规则、问题、证据分层,不要把检索内容伪装成 assistant

const input = [
  {
    role: "developer",
    content: "仅依据提供资料回答,不确定时明确说明。",
  },
  {
    role: "user",
    content: "文档里如何定义 RLS",
  },
  // 检索结果作为独立证据输入
];

工具调用型 Agent

流程通常是规则定义、任务输入、模型决策、工具返回、最终回复。关键点始终是证据层和历史层分离。

这一段也可以直接用一张图讲透,重点表达每个角色的禁放内容、统一分层原则和高频错误。

如下图所示:

image.png

图里按左中右依次呈现禁放项、分层原则、错误清单,读者扫一眼就能建立正确的上下文组织习惯。

总结

LLM 传递上下文时,role 不是身份扮演,它是上下文架构的第一层。核心角色可以记成四个:

  • developer 负责应用规则
  • system 负责全局边界
  • user 负责当前任务
  • assistant 负责历史承接

工具返回、检索结果、数据库结果这类外部事实,应单独进入证据层。一句话总结这套方法就是,规则、任务、历史、外部事实、生成目标必须分层,各归各位。只要这件事做对,很多看起来像模型能力问题的现象,最终都能回到可治理的上下文工程问题。 如果把这套原则再压缩成一条执行口令,就是谁定义规则、谁提出任务、谁给出证据、谁负责输出,都必须放在各自那一层,不能混写。结构一旦干净,后续 prompt 设计、RAG 召回和 Agent 调试都会明显轻松。

【译】我的 AI 进阶之路:从怀疑到深度整合

作者 清汤饺子
2026年4月29日 15:04

Harness Engineering 最近特别火,一起来读读这个术语的源头文章,Mitchell Hashimoto 在 2026-02-05 发的: My AI Adoption Journey

以下是经过整理后的中文版,感兴趣的朋友可以移步英文原版:My AI Adoption Journey

真正有价值的 AI 开发,不是让 AI 一次性写出完美代码,而是给 Agent 一个能行动、验证、纠错、积累经验的环境。

目录

  • 第 1 步:告别聊天机器人
  • 第 2 步:复现你自己的工作
  • 第 3 步:部署“下班后”智能体
  • 第 4 步:外包那些“稳赢”的任务
  • 第 5 步:构建“工程化约束” (Harness Engineering)
  • 第 6 步:让智能体永不掉线
  • 现状与思考

我在上手任何有意义的工具时,必然会经历三个阶段:

(1) 效率阵痛期

(2) 勉强够用期

(3) 工作流重塑与突破期

大多数情况下,我得强迫自己熬过前两个阶段。毕竟我早已习惯了现有的工作流,用起来既顺手又舒服。

拥抱新工具就像是在“加班”,我本心并不想折腾,但为了保持专业素养,成为一个更纯粹的“手艺人”,我通常会选择坚持。

这是我如何发掘 AI 工具价值的心路历程。

在当下充斥着浮夸、炒作的舆论大海中,我希望分享一种更细腻、更克制的视角,记录我的观念是如何随时间演变的。

第 1 步:告别聊天机器人

立即停止尝试通过聊天界面(如网页版 ChatGPT、Gemini 等)来处理正式工作。

聊天机器人当然有价值,也是我日常流的一部分,但它们在编程中的作用非常有限。因为你本质上是在赌它能靠以前的训练“蒙”对结果;一旦它错了,你还得像教小孩一样反复告诉它哪儿错了。这种“你一言我一语”的纠错极其低效。

我第一个“真香”时刻,是将 Zed 编辑器的命令面板截图发给 Gemini,让它用 SwiftUI 复现。结果令我震惊——它做得非常棒。现在 Ghostty macOS 版中的命令面板,基本就是 Gemini 几秒钟内生成的初版。

但当我试图在更复杂的“棕地项目”(Brownfield projects,指在现有代码库上开发)中复现这种成功时,我失望了。在复杂的上下文里,聊天机器人经常翻车,我得不停地在编辑器和网页间反复粘贴代码和错误日志。这显然比我自己写要慢得多。

结论:要产生真正的生产力,你必须使用 Agent(智能体)。

Agent 是指能够在一个循环中运行、并能调用外部工具的 LLM。

它至少得具备::

  • 读项目文件
  • 执行命令
  • 调用工具
  • 根据结果继续修正
  • 在真实仓库里工作

换句话说,Agent 让 AI 从“回答问题的人”变成了“能操作项目的协作者”。

第 2 步:让 Agent 复刻自己的工作

我尝试了 Claude Code。起初印象平平:结果不理想,我还得给它“擦屁股”,花的时间比自己写还长。

但我没有放弃,而是强迫让 Agent 把我刚写完的代码重写一遍。

我相当于把活儿干了两遍:先手写,然后让 Agent 在看不到我答案的情况下,挑战达到同样的质量和功能。

过程很痛苦,因为它阻碍了我的进度。但作为一个老兵,我知道这种摩擦是必然的。

这样做是为了校准:

  • Agent 哪些任务能做
  • 哪些任务容易失败
  • 任务应该怎么拆
  • 什么验证方式能帮 Agent 自己发现问题

我发现,

我总结出了几条核心原则:

  1. 任务拆解: 别指望一步到位,要把任务拆成清晰、可执行的小块。
  2. 规划与执行分离: 模糊的需求要先让 AI 出方案,再执行。
  3. 闭环验证: 给 Agent 明确的验证路径,比如测试脚本、截图、lint,它通常能自己修好 Bug。

在这个阶段,我摸清了 Agent 的边界,不再盲目使用,达到了“不比手写慢”的平衡点。

第 3 步:部署“下班后”智能体

为了榨取效率,我开启了一个新模式:每天下班前最后 30 分钟,启动一个或多个 Agent。

既然我没法 24 小时工作,那就让 Agent 在我休息时帮我推点进度。

我发现这几类工作特别适合“离线运行”:

  • 深度调研: 比如“调研某语言下所有符合某种授权协议的库,整理优缺点、活跃度和社区口碑”。
  • 方案探索: 尝试我脑子里一些不成熟的点子,第二天看 Agent 的尝试是否帮我排雷。
  • Issue 过滤: 让 Agent 用 GitHub CLI 把积压的 Issue 过一遍,打好标签,我第二天就能直接处理高价值任务。

重点不是 Agent 一定要直接产出可合并代码,而是让第二天的工作有一个“热启动”。

第 4 步:把高确定性任务交给 Agent

当我足够了解 Agent 的能力边界后,我开始把那些它肯定能搞定的任务彻底外包出去,而我同时去干别的事。

适合 Agent 的任务通常是:

  • 范围清晰
  • 验证明确
  • 有现成模式可参考
  • 改动风险可控

不适合 Agent 的任务通常是:

  • 需求模糊
  • 架构判断重
  • 缺少测试
  • 失败代价高

关键点:关掉 Agent 的桌面通知。 频繁的上下文切换是效率杀手。

作为人类,我要掌控干扰的时机。

在我工作的自然间隙,切过去扫一眼 Agent 的进度即可。

这种方式让我能专注于那些我真正热爱、需要深度思考的代码,而把琐碎但必须做的杂事交给这位“略显笨拙但任劳任怨”的机器人助手。

第 5 步:构建“工程化约束” (Harness Engineering)

Agent 的效率取决于它能不能“一次跑对”。实现这一点最可靠的方法不是写更长提示词,而是给它一套快速、高质量的工具,让它在犯错时能立刻察觉。

我称之为 “Harness Engineering”(线束/约束工程)

只要 Agent 犯了一次错,我就去写个脚本或更新配置,确保它以后再也不会犯同样的错:

第一种是更新规则文件,比如 AGENTS.md。

如果 Agent 总是:

  • 跑错命令
  • 用错 API
  • 忽略项目规范
  • 改错文件
  • 重复犯同类错误

那就记录各种避坑指南和规范,把规则写进项目说明里,让后续 Agent 能继承这次经验,运行时隐式加载。

第二种是写真正的工程工具。

比如:

  • 自动测试脚本
  • 截图验证工具
  • lint 规则
  • 类型检查
  • 架构约束
  • 本地检查命令

规则文件是“告诉 Agent 不要犯错”,工具和检查是“让 Agent 更容易发现自己错了”。

这就是 Harness Engineering 的核心:把每次错误沉淀成可复用的约束、工具和反馈回路。

第 6 步:让 Agent 永不掉线

现在,我的目标是只要我在电脑前,背景里就一定有一个 Agent 在跑。

如果它没在跑,我会问自己:“现在有什么事是可以分给它做的吗?”

我尤其喜欢用一些“慢而深”的模型(如 Amp 的 Deep Mode),它们可能要跑 30 分钟才能完成一点小改动,但质量极高。

重点是找到那些 Agent 真能推进的任务,让它成为异步生产力。

人类做判断、拆任务、评审和设计环境;Agent 做执行、尝试和重复劳动。

我目前还没打算同时跑多个 Agent。一个背景 Agent 能让我保持专注,同时又像有一个“有点呆但出活”的机器人在旁边搭把手,这种平衡感刚好。

现状与思考

这就是我目前的进度。

我成了一名依然热爱手艺、但学会了使用现代重型武器的软件工匠。

我并不太在意 AI 是否会取代人类,我只想纯粹地因为热爱而创造。

这个领域变化太快,也许几个月后回看此文我会觉得自己很幼稚。

但正如那句话所说:如果你不为过去的自己感到羞愧,说明你没有进步。

总结

这篇文章的价值不在于“AI 很强”,而在于它提出了一种工程师的新职责:

  • 不只是写代码,而是设计 Agent 能稳定工作的环境
  • 不只是修 bug,而是让同类 bug 不再发生
  • 不只是 prompt,而是规则、工具、测试、反馈、约束一起构成系统

真正成熟的 AI coding 工作流,不是靠神奇提示词,而是靠持续建设 harness,让 Agent 在一个可控、可验证、可积累的工程环境里工作。

如何实现一个网页版的剪映(五)如何跳转到视频某一帧

作者 贾铭
2026年4月29日 09:45

如何实现一个网页版的剪映(一)简介

如何实现一个网页版的剪映(二)深入webcodecs

如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴

如何实现一个网页版的剪映(四)使用插件化思维创建pixi绘制画布(转场/滤镜)

如何实现一个网页版的剪映(五)如何跳转到视频某一帧


# 如何实现一个网页版的剪映(一)简介讲过webav是如何seek某一帧的,我们来回顾一下

源码入口是一个tick函数

/**
 * 获取素材指定时刻的图像帧、音频数据
 * @param time 微秒
 */
async tick(time: number): Promise<{
  video?: VideoFrame;
  audio: Float32Array[];
  state: 'success' | 'done';
}> {
  if (time >= this.#meta.duration) {
    return await this.tickInterceptor(time, {
      audio: (await this.#audioFrameFinder?.find(time)) ?? [],
      state: 'done',
    });
  }

  const [audio, video] = await Promise.all([
    this.#audioFrameFinder?.find(time) ?? [],
    this.#videoFrameFinder?.find(time).then(this.#vfRotater),
  ]);

  if (video == null) {
    return await this.tickInterceptor(time, {
      audio,
      state: 'success',
    });
  }

  return await this.tickInterceptor(time, {
    video,
    audio,
    state: 'success',
  });
}

WebAV 是如何seek的

核心类在 VideoFrameFinder

什么时候会 Reset(等价于 Seek 重建解码状态)

在 find 里,满足任一条件就会 #reset(time) :

find = async (time: number): Promise<VideoFrame | null> => {
    if (
      this.#dec == null ||
      this.#dec.state === 'closed' ||
      time <= this.#ts ||
      time - this.#ts > 3e6
    ) {
      this.#reset(time);
    }

    this.#curAborter.abort = true;
    this.#ts = time;

    this.#curAborter = { abort: false, st: performance.now() };
    const vf = await this.#parseFrame(time, this.#dec, this.#curAborter);
    this.#sleepCnt = 0;
    return vf;
  };
  • 解码器不存在/已关闭
  • time <= 上一次的 time (倒退 seek)
  • time - 上一次的 time > 3e6 (跨度超过 3 秒,认为是 seek)

Reset 做了几件关键事 #reset :

  • 清空缓存的 VideoFrame 队列(并 close)
  • 关闭并重建 VideoDecoder (必要时会降级软件解码)
  • 最重要: 把解码游标 #videoDecCusorIdx 移到“目标时间点之前最近的 IDR 帧”
    • 逻辑是扫描 samples:不断更新 keyIdx (遇到 s.is_idr ),当第一次看到 s.cts >= time 时,把 cursor 设为 keyIdx

这一步决定了: 视频定位不是直接“找 time 对应的那一帧 sample”就解,而是必须从关键帧开始解码 GOP,才能得到后续帧。

parseFrame:从“解码输出队列”里挑出覆盖 time 的那一帧

#parseFrame = async (
  time: number,
  dec: VideoDecoder | null,
  aborter: { abort: boolean; st: number },
): Promise<VideoFrame | null> => {
  if (dec == null || dec.state === 'closed' || aborter.abort) return null;

  if (this.#videoFrames.length > 0) {
    const vf = this.#videoFrames[0];
    if (time < vf.timestamp) return null;
    // 弹出第一帧
    this.#videoFrames.shift();
    // 第一帧过期,找下一帧
    if (time > vf.timestamp + (vf.duration ?? 0)) {
      vf.close();
      return await this.#parseFrame(time, dec, aborter);
    }

    if (!this.#predecodeErr && this.#videoFrames.length < 10) {
      // 预解码 避免等待
      this.#startDecode(dec).catch((err) => {
        this.#predecodeErr = true;
        this.#reset(time);
        throw err;
      });
    }
    // 符合期望
    return vf;
  }

  // 缺少帧数据
  if (
    this.#decoding ||
    (this.#outputFrameCnt < this.#inputChunkCnt && dec.decodeQueueSize > 0)
  ) {
    if (performance.now() - aborter.st > 6e3) {
      throw Error(
        `MP4Clip.tick video timeout, ${JSON.stringify(this.#getState())}`,
      );
    }
    // 解码中,等待,然后重试
    this.#sleepCnt += 1;
    await sleep(15);
  } else if (this.#videoDecCusorIdx >= this.samples.length) {
    // decode completed
    return null;
  } else {
    try {
      await this.#startDecode(dec);
    } catch (err) {
      this.#reset(time);
      throw err;
    }
  }
  return await this.#parseFrame(time, dec, aborter);
};

#parseFrame(time, dec, aborter)的第一优先级是消费 #videoFrames 缓存队列:

  • 若队列非空,取队头 vf = videoFrames[0]
  • time < vf.timestamp :说明 当前缓存最早帧都比目标 time 晚 ,直接返回 null
  • 否则 shift 弹出这一帧,然后判断是否“过期”:
    • time > vf.timestamp + (vf.duration ?? 0) :目标 time 已经超过这帧覆盖区间,close 掉继续递归找下一帧
    • 否则:这帧覆盖了 time ,直接返回它

因此,“寻找帧”的判定标准就是:vf.timestamp <= time <= vf.timestamp + vf.duration

如果缓存里没有帧,就推进解码(按 GOP 批量 decode)

当 #videoFrames 为空时, #parseFrame 会进入“要么等解码完成,要么启动新解码”的状态机:

  • 如果正在解码 / 或者 decodeQueue 里还有待输出:睡眠 15ms 再重试,并带 6s 超时保护
  • 如果 cursor 已经到 sample 末尾:返回 null
  • 否则调用 #startDecode(dec) 推进一段 GOP 解码

startDecode:如何切出一个 GOP、读数据、喂给 VideoDecoder(最关键的代码)

#startDecode = async (dec: VideoDecoder) => {
  if (this.#decoding || dec.decodeQueueSize > 600) return;

  // 启动解码任务,然后重试
  let endIdx = this.#videoDecCusorIdx + 1;
  if (endIdx > this.samples.length) return;

  this.#decoding = true;
  // 该 GoP 时间区间有时间匹配,且未被删除的帧
  let hasValidFrame = false;
  for (; endIdx < this.samples.length; endIdx++) {
    const s = this.samples[endIdx];
    if (!hasValidFrame && !s.deleted) {
      hasValidFrame = true;
    }
    // 找一个 GoP,所以是下一个 IDR 帧结束
    if (s.is_idr) break;
  }

  if (hasValidFrame) {
    const samples = this.samples.slice(this.#videoDecCusorIdx, endIdx);
    if (samples[0]?.is_idr !== true) {
      Log.warn('First sample not idr frame');
    } else {
      const readStarTime = performance.now();
      const chunks = await videosamples2Chunks(samples, this.localFileReader);

      const readCost = performance.now() - readStarTime;
      if (readCost > 1000) {
        const first = samples[0];
        const last = samples.at(-1)!;
        const rangSize = last.offset + last.size - first.offset;
        Log.warn(
          `Read video samples time cost: ${Math.round(readCost)}ms, file chunk size: ${rangSize}`,
        );
      }
      // Wait for the previous asynchronous operation to complete, at which point the task may have already been terminated
      if (dec.state === 'closed') return;

      this.#lastVfDur = chunks[0]?.duration ?? 0;
      decodeGoP(dec, chunks, {
        onDecodingError: (err) => {
          if (this.#downgradeSoftDecode) {
            throw err;
          } else if (this.#outputFrameCnt === 0) {
            this.#downgradeSoftDecode = true;
            Log.warn('Downgrade to software decode');
            this.#reset();
          }
        },
      });

      this.#inputChunkCnt += chunks.length;
    }
  }
  this.#videoDecCusorIdx = endIdx;
  this.#decoding = false;
};

首先需要先知道的几个个概念

  • 关键帧(sync frame) :能独立解码的帧,H.264/265 里通常是 IDR。代码里用 s.is_idr 作为 GOP 的分界。(更详细的解释看# 如何实现一个网页版的剪映(一)简介
  • GOP :从一个 IDR 到下一个 IDR 之前的那一段帧序列。解码时通常要从 GOP 开头开始喂数据,才能正确得到中间的 delta 帧。
  • cursor(游标) : #videoDecCusorIdx 表示“下一次准备从 samples 的哪个下标开始喂给解码器”。

先判断“要不要启动一次解码”

if (this.#decoding || dec.decodeQueueSize > 600return;
  • #decoding :防止重复并发启动(一次还没结束又启动一次)。
  • decodeQueueSize > 600 :解码器内部积压太多了就先别喂,避免爆内存/卡死。

确定这次要处理的范围:从 cursor 往后找到一个 GOP 的“结束位置”

let endIdx = this.#videoDecCusorIdx + 1;
...
for (; endIdx < this.samples.length; endIdx++) {
  const s = this.samples[endIdx];
  ...
  if (s.is_idrbreak;
}

endIdx 往后走,直到遇到下一个关键帧,这就相当于找到了 [start, endIdx) 这一段 GOP (从当前关键帧开始,到下一个关键帧之前结束)

喂给 VideoDecoder 解码(异步产出 VideoFrame)

this.#lastVfDur = chunks[0]?.duration ?? 0;
decodeGoP(dec, chunks, { onDecodingError ... });
this.#inputChunkCnt += chunks.length;

function decodeGoP(
  dec: VideoDecoder,
  chunks: EncodedVideoChunk[],
  opts: {
    onDecodingError?: (err: Error) => void;
  },
) {
  if (dec.state !== 'configured') return;
  for (let i = 0; i < chunks.length; i++) dec.decode(chunks[i]);

  // todo:flush 之后下一帧必须是 IDR 帧,是否可以根据情况再决定调用 flush?
  // windows 某些设备 flush 可能不会被 resolved,所以不能 await flush
  dec.flush().catch((err) => {
    if (!(err instanceof Error)) throw err;
    if (
      err.message.includes('Decoding error') &&
      opts.onDecodingError != null
    ) {
      opts.onDecodingError(err);
      return;
    }
    // reset 中断解码器,预期会抛出 AbortedError
    if (!err.message.includes('Aborted due to close')) {
      throw err;
    }
  });
}

decodeGoP 做的事很直接:

  • 循环 dec.decode(chunk) 把 chunk 都送进去
  • 调 dec.flush() (但不 await)让解码器尽快把队列处理完

重要: startDecode 本身 不会在这里等到 VideoFrame 真正出来 。帧是通过 new VideoDecoder({ output(vf) { ... } }) 的 output 回调异步推入 #videoFrames 缓存的。

最后推进 cursor,并解除 “正在解码” 状态

this.#videoDecCusorIdx = endIdx;
this.#decoding = false;

这一步非常关键:它决定下一次 startDecode 会从哪里继续喂下一段 GOP。

MediaBunny 是如何seek的

// 一次取单个时间点
const sample = await videoSink.getSample(1.25);

// 一次取多个时间点
for await (const sample of videoSink.samplesAtTimestamps([0.5, 1.0, 1.5])) {
console.log(sample); // MediaSample 或 null
}

MediaBunny 的 seek,和webav一样,本质是三步:

  1. 定位目标帧(target packet)
  2. 找到对应关键帧(key packet)
  3. 从关键帧开始解码到目标帧(按批次)

getSample底层会调mediaSamplesAtTimestamps这个函数,其中getKeyPacket就是获取关键帧的函数

mediaSamplesAtTimestamps部分代码如下

for await (const timestamp of timestampIterator) {
  // getPacket(timestamp) 取“表示这个时间点内容”的目标包。
  const targetPacket = await packetSink.getPacket(timestamp);
  // getKeyPacket(timestamp, { verifyKeyPackets: true })会定位该时间点可用的关键包,
  // 并校验关键包标记,避免容器元数据不准导致错误解码。
  const keyPacket =
    targetPacket &&
    (await packetSink.getKeyPacket(timestamp, {
      verifyKeyPackets: true,
    }));

  if (!keyPacket) {
    if (maxSequenceNumber !== -1) {
      await decodePackets();
      await flushDecoder();
    }

    pushToQueue(null);
    lastPacket = null;
    continue;
  }

  // 如果关键帧变了,或者请求时间戳发生“倒退”,说明不能继续复用当前这批解码状态,
  // 需要先把上一批收尾并清空解码器状态,再开启新批次。
  if (
    lastPacket &&
    (keyPacket.sequenceNumber !== lastKeyPacket!.sequenceNumber ||
      targetPacket.timestamp < lastPacket.timestamp)
  ) {
    await decodePackets();
    await flushDecoder(); // 这里始终 flush,一些解码器在这种切换场景下兼容性更好。
  }

  // 记录这个时间戳最终实际要匹配的样本起始时间。
  timestampsOfInterest.push(targetPacket.timestamp);
  // 批次终点取多个目标包中的最大序号,这样相邻请求可以复用同一轮解码。
  maxSequenceNumber = Math.max(
    targetPacket.sequenceNumber,
    maxSequenceNumber,
  );

  lastPacket = targetPacket;
  lastKeyPacket = keyPacket;
}

从关键帧解码到目标帧(核心)

// 下一批需要解码到的结束序号(包含)。
// 每一批都从 `lastKeyPacket` 开始,一直解码到这个序号为止。
let maxSequenceNumber = -1;

const decodePackets = async () => {
  // 从当前关键帧开始解码,确保解码器拥有正确的参考状态。
  let currentPacket = lastKeyPacket;
  decoder.decode(currentPacket);

  while (currentPacket.sequenceNumber < maxSequenceNumber) {
    // `computeMaxQueueSize()` 根据当前已解码样本数动态决定允许的总排队量,避免占用过多内存。
    const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
    while (
      sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize &&
      !terminated
    ) {
      // 队列太满时暂停继续喂包,等消费者取走一些样本后再继续。
      ({ promise: queueDequeue, resolve: onQueueDequeue } =
        promiseWithResolvers());
      await queueDequeue;
    }

    if (terminated) {
      break;
    }

    // `getNextPacket()` 按编码顺序拿到当前包之后的下一个包。
    const nextPacket = await packetSink.getNextPacket(currentPacket);

    decoder.decode(nextPacket);
    currentPacket = nextPacket;
  }

  maxSequenceNumber = -1;
};

getKeyPacket是怎么获取关键帧的

入口函数如下

async getKeyPacket(
  timestamp: number,
  options: PacketRetrievalOptions = {},
): Promise<EncodedPacket | null> {
  validateTimestamp(timestamp);
  validatePacketRetrievalOptions(options);

  if (this._track.input._disposed) {
    throw new InputDisposedError();
  }

  if (!options.verifyKeyPackets) {
    return this._track._backing.getKeyPacket(timestamp, options);
  }

  const packet = await this._track._backing.getKeyPacket(timestamp, options);
  if (!packet || packet.type === "delta") {
    return packet;
  }

  const determinedType = await this._track.determinePacketType(packet);
  if (determinedType === "delta") {
    // Try returning the previous key packet (in hopes that it's actually a key packet)
    return this.getKeyPacket(
      packet.timestamp - 1 / this._track.timeResolution,
      options,
    );
  }

  return packet;
}

this._track._backing.getKeyPacket是内部的函数,如果不做验证这帧是不是关键帧,就直接返回

但是会有这种情况:有些文件写错了 keyframe 标记,会返回“看起来是 key、实际是 delta”的包,导致解码器报错

接着,就会进行校验

  1. 先向下拿候选 key packet;
  2. 如果没拿到,或底层直接说它是 delta,就返回
  3. 如果底层说它是 key,那就“扒开码流看一眼”它到底是不是 key,会解析码流(比如 H.264 看有没有 IDR NAL)来判断

this._track._backing.getKeyPacket如下

/**
 * 按时间戳取“关键帧”数据包。
 *
 * 取包有两条路:
 * 1) 文件自带“索引表”(普通 MP4/MOV 常见):先用索引表找到这个时间附近的 sample,再往前退到最近的关键帧。
 * 2) 没有索引表、而是“分段存储”(fMP4 常见):就去各个分段里找“时间 <= 目标时间”的最后一个关键帧。
 */
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
  // 外部传进来的 timestamp 是“秒”,内部查找用的是 track 自己的时间单位(timescale)
  const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);

  // 先尝试走“索引表”这条更快的路:从索引表定位到 sample,再找到对应关键帧
  const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(
    this.internalTrack,
  );
  const sampleIndex = getSampleIndexForTimestamp(
    sampleTable,
    timestampInTimescale,
  );
  const keyFrameSampleIndex =
    sampleIndex === -1
      ? -1
      : getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
  const regularPacket = await this.fetchPacketForSampleIndex(
    keyFrameSampleIndex,
    options,
  );

  // 只要索引表里有内容,或者这个文件不是分段格式,就用上面这条“索引表”结果
  if (
    !sampleTableIsEmpty(sampleTable) ||
    !this.internalTrack.demuxer.isFragmented
  ) {
    return regularPacket;
  }

  // 索引表为空 + 分段格式:改为到分段里去找关键帧
  return this.performFragmentedLookup(
    null,
    (fragment) => {
      const trackData = fragment.trackData.get(this.internalTrack.id);
      if (!trackData) {
        return { sampleIndex: -1, correctSampleFound: false };
      }

      // 在这个分段里,从后往前找:
      // 最后一个 “是关键帧 && 时间戳 <= 目标时间” 的 sample
      const index = findLastIndex(trackData.presentationTimestamps, (x) => {
        const sample = trackData.samples[x.sampleIndex]!;
        return (
          sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale
        );
      });

      const sampleIndex =
        index !== -1
          ? trackData.presentationTimestamps[index]!.sampleIndex
          : -1;
      // 如果目标时间戳就在这个分段的时间范围内,说明已经找到“正确分段”了,不用继续翻别的分段
      const correctSampleFound =
        index !== -1 && timestampInTimescale < trackData.endTimestamp;

      return { sampleIndex, correctSampleFound };
    },
    timestampInTimescale,
    timestampInTimescale,
    options,
  );
}

讲讲关键的函数:

  • fetchPacketForSampleIndex按 sample 索引读取文件字节 + 组装 packet 元信息(也就是生成一个EncodedPacket对象,这是mediabunny封装的EncodedVideoChunk)
const packet = new EncodedPacket(
  data,
  sampleInfo.isKeyFrame ? 'key' : 'delta',
  timestamp,
  duration,
  sampleIndex,
  sampleInfo.sampleSize,
);
  • getSampleTableForTrack作用是: 为某个 MP4 track 构建并缓存 sample table(把 MP4 的 stbl(stts/ctts/stsz/stco/co64/stsc/stss 等)解析成内部可用的索引结构),把后续随机访问(按时间取包/找关键帧/取下一帧)需要的索引数据准备好
  • return this.performFragmentedLookup当常规 moov 里的 sample table 为空(或拿不到样本),但文件是 fragmented 的时候,就去扫描后续的 moof fragment,在其中找到时间戳 ≤ 目标时间的最近关键帧并返回对应的 packet

fragmented 指的是“分片/分段的 MP4”(常见叫 fMP4,fragmented MP4),也就是:媒体数据不是一次性在 moov 里用完整的 sample table 描述完,而是被切成很多段,每段用一个 moof (Movie Fragment box)+ 对应的 mdat 来描述/承载。

2026 年,AI 全栈时代到了,前端简历别再只写前端技术了 🫠🫠🫠

作者 Moment
2026年4月28日 20:29

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

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

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

最近我给一些前端方向的实习生做内推,看了不少简历。投递里常能看出一种预设,找实习只要把前端做熟,页面和接口能啃下来,似乎就踩对了主线。初筛读多了会有另一种感受,当业务和岗位已经大量贴上大模型、RAGAgent 时,纯前端技术栈写得再工整,也很难在一叠写法雷同的简历里单独把人托起来。

我想写的不是简历技巧,评审更常问的是,你有没有把智能接进一条可维护、可观测、也能和人协作的链路,还是只在项目名里多写几个关键词。

许多项目经历段落,技术名词很全,叙事却薄。写得多的几种模式是:

  • 周期很短,却同时堆上 RAG、流式输出、鉴权与复杂治理,读者很难估计真实投入与掌握深度
  • 段落像功能清单,缺少场景、难点、个人职责与可验证结果,性能数字也缺少口径与复现方式
  • 形态集中在教程型对话台或后台管理,技术栈高度同质,差异化不明显
  • Agent 被写成调模型、接工具,很少触及运行时、状态机、评测、观测与人在回路

还有一种写法,整段都在堆大家简历里常见的工程项,例如 JWT、双 tokenRBAC、请求拦截器、SSEWebSocket 流式、分片上传、虚拟列表。十条里七八条读起来像同一篇教程拆出来的,名词齐了,却看不出你解决的是哪一道别人没写清楚的难题。基本功当然要会,可若差异化只停在这一层,筛简历的人很难把你从同质化描述里拎出来。

简历上的项目经历一撞脸,筛简历的人就只好盯你有没有把系统做实。后面我按层写。

这两年简历里写 Agent 的人越来越多,细看实现却常常撞脸,核心路径几乎总是同一条。

接收用户输入,调用大模型,解析工具调用,执行工具,返回结果。

引用里那几步,无非是调模型、解析工具调用、执行、再把结果塞回模型。脚手架和跟练多了,半天跑通一个 Demo 很常见。评审里想听的,往往是上线以后要应付的那些事,超时、重试、成本、人要确认、出了事故怎么查和改,你有没有提前铺过路。

面试里真正该往下问的,早就不该是这种技能清单:

  • 你会不会调 LLM
  • 你会不会接 Tool
  • 你会不会用 LangChain

而是更底层的这个问题:

你有没有把 Agent 当成一个系统,而不是一个函数调用?

能讲清楚这一句,面试官才会继续往下问。落地时大家会盯这几件事有没有做实,有没有进真实产品而不是停在演示分支:

  • 有没有独立的 Agent Runtime
  • 有没有显式状态机驱动的 Agent Loop
  • 有没有把评测做成回归闸门
  • 有没有把观测、检测、红队、安全、成本和用户干预整成闭环
  • 能不能把这些能力真正接进产品,而不是停留在一段演示代码

缺了这些,名字再响亮,多半仍是一个带工具调用的聊天接口。本文不写怎么接 OpenAI、怎么声明 Tool,只写骨架。

从 Demo 到产品,Agent 系统到底还差哪几层骨架。

下面按层拆开说明。

为什么很多 Agent 项目能跑,但没有技术区分度

很多人会以为把大模型、多轮、ToolMemoryRAG 勾齐,项目就算做完了。那点东西多半只盖住 demo 里的顺利路径,一遇到真实流量,缺的是运行时、安全、观测和评测这一整圈骨架,而不是再多接一个模型。

它们看起来像 Agent,实际上更像一个带工具调用的聊天接口。

一放量,问题会先挤在几块地方,很少单靠改一段 Prompt 就能压住:

  • 同一类提问有时成有时败,工具忽对忽错
  • 用户只看到转圈,不知道卡在推理还是在等工具
  • 线上成功率掉了,分不清是模型、工具还是外部 API
  • Prompt 改完自测像变聪明,线上指标却掉头
  • 偏航多步也没有让人半途介入的口子
  • token 和钱烧在哪些步骤,心里没底

根本原因多半不在 Prompt 花不花或 Tool 多不多,而在下面几块是否长期空白:运行时、状态机、可观测、评测、风险、HITLStreaming、成本、异常和线上告警。填不全,项目就会一直像在交课堂作业。只会用 LangChain 也不等于会做 Agent,框架主要管编排,编排以外的那一圈才是评审想听你讲清楚的。

前端加 LangChain 开发者的真正优势

前端背景再叠上 LangChainLangGraph、工具与记忆,常被低估。和算法岗比的不是论文厚度,而是能不能把智能体做成别人能长期点着用的产品。

你更占便宜的地方,是把 Agent 做成用户看得见、停得下来、出了问题能对上账的系统。

模型只是其中一个节点。好不好用还要看,现在在干什么、为什么选这个工具、失败怎么补、用户能不能打断、高风险要不要确认、耗时和钱能不能对上账、改完 Prompt 有没有回归、输出能不能验、输入和工具参数有没有护栏、线上有没有告警。这些早就不是单次推理,而是一整条链路的工程活。状态、异步、中间态、确认、埋点和展示,本来就是前端日常,这块你会比只写脚本的人更顺手。

介绍自己时不必缩成会接某家 API 的前端,可以收成一句实在话。

我负责把 LLM 编排、工具、状态机、可观测、评测和交互收成一条,给人用的是产品不是脚本。

交付物也更该像任务控制台,把推理、调工具、等确认、报错恢复这些阶段摊开,而不是聊天框里一条接一条的气泡。

把 Agent 理解成运行中的系统而不是调用链

先把问题问清楚,这东西在产线里更像一次短请求,还是像一趟要跑很久的任务。

不要把 Agent 理解为一次请求的处理流程,而要把它理解为一个持续运行的任务系统。

普通接口大约四步,请求进来,处理完就结束。Agent 更像长跑任务,中间状态多。下图是一条典型阶段划分,提醒自己别再用短接口的思维去估长任务。

20260423093052

图的意思很直白,Agent 更接近任务引擎,而不是只吐一段字的接口。接下来这些问题躲不掉,状态放哪、刷新后怎么续、工具超时重试还是交给人、高风险要不要审批、token 快顶了还跑不跑、工具能不能并行、连走几步没进展算不算打转。这些都算系统设计,不是多写两行 Prompt 能糊过去的。

一个有区分度的 Agent 系统应有的分层

Agent 如果只停在调模型、调工具,听起来总像缺一块。动手前最好先想清楚,界面交互、流程编排、运行时控制、安全治理、可观测和评测各自归谁管,别全糊进一条链。

对外说法可以很简单,Agent 像一条流水线:

用户输入 → 模型推理 → 调用工具 → 返回结果

真要拆职责,可以收成六层:

  • 交互层:用户看得见、点得着的界面,负责步骤展示、审批、中断、重试和结果反馈
  • 编排层:用 LangChainLangGraph 等把 Prompt、模型、工具、记忆和状态流转组织成可维护的流程
  • 运行时 Harness:管理步数、超时、预算、快照、重试、取消和收尾,决定任务如何真正跑完或安全停下
  • 安全与检测层:在输入、工具执行前、输出和轨迹上做规则与模型检测,拦住不该发生的行为
  • 可观测层:用 Trace、Metrics、日志把每一步变成可查询、可对比、可回放的事实
  • 评测层:通过离线集、回归闸门和线上灰度,用数据判断一次改动到底有没有变好

编排层按意图出计划和工具调用,运行时层在预算、超时和状态约束下执行。执行中安全层可能拦截或要审批,可观测层记全程,评测层再拿这些记录去约束下一版 Prompt、节点、工具和发版节奏。

分层不是为了把图画复杂,而是别把运行时、安全、回放、评测和灰度都指望 LangChain 自动搞定。框架主要管编排,编排外那一圈才决定工程含量。

用户意图从交互层进编排层,编排层再把可执行步骤交给运行时。

image.png

运行时不只是把流程跑完,还要在执行过程中持续接入安全检测和可观测能力。

image.png

可观测记录下来的事实,会进入评测体系,再反向约束下一轮编排和发布策略。

image.png

每层用一句话带过,细节可以另写。

交互层负责摊开给人看、给人控。过程可见、风险写操作前要确认、能中断和改向,这些要和运行时对齐,别事后在文案里补两行提示。

编排层用 PromptToolMemory、图或链把节点串起来。LangChain 一类框架主要管这一层,观测、中断、预算、版本和 bad case 回流多半在编排之外,别把欠账算在框架头上。

运行时层用 Harness 钉死步数、单步和整体超时、token 和墙钟预算、取消和收尾。结束由状态和 Harness 判定,预算触顶该降级就降级,别指望模型自己说做完了。

安全与检测要盖住输入、工具执行前、输出和轨迹。模型吐出来的 tool call 只是草稿,执行前要走白名单、schema、权限和风险分级,高危路径该审批就审批。

可观测层靠 Trace、分层指标和结构化日志,把一次任务从猜变成查,后面才好做归因、回放和调参。

评测层用离线用例、回归闸门和线上灰度回答有没有变好。Prompt、模型、工具或状态机一动,就该自动对比基线,线上反馈要能回灌进用例集。

六层都沾到,才像能交给别人托管的产品,而不是只证明链路能跑通的 Demo

Agent Loop 应该是显式状态机而不是 while 循环

最朴素的 Agent Loop 是反复调模型、判断是否调工具、拿结果再回模型,直到产出答案。这个流程能跑通,但一进真实场景就容易失控,因为很多关键分支塞不进一个裸 while 循环。

真正容易栽跟头的几件事:

  • 工具失败后的重试与止损
  • 高风险动作前的人工确认
  • 预算触顶后的降级与收尾
  • 上下文过长时的压缩与续跑
  • 用户中断后的恢复与回放

Loop 收成显式状态机,让系统在 ReasoningToolSelectingExecutingAwaitingConfirmationRecoveringFinalizing 等状态之间按条件跳转,分支写在表里,比藏在 if 里好查也好测。

状态机写清楚以后,日常会顺很多。状态一眼能看见,分支不再散在 if 里,前后端对得上号,暂停、恢复、撤销、重试也好接。

把它和前面的 AgentHarness 组合后,职责会更清晰:

  • Harness 负责时间、步数、token、取消和强制收尾
  • 状态机负责业务语义、路径选择和异常分支

上线以后,Loop 往往还要挂审批、检测、回滚、埋点和评测,能挂在状态切换点上就别散在业务代码里。

收个尾,Agent Loop 不该只是会转的循环,最好收成一台可解释、可干预、可恢复、也能审计的状态机。

把人设计进系统而不是把人当兜底

很多团队把 HITL 理解成出错后的兜底,这会让人机协同长期停在救火阶段。设计阶段就把哪些动作自动放行、哪些必须确认、哪些默认拒绝写清楚,比上线后救火省事。

HITLHuman-in-the-loop,意思是把人放进关键决策回路。系统负责执行与提议,人负责在高风险节点确认、纠偏和兜底。

风险分级可以先从三档起步,阈值和白名单由业务与合规共同维护:

  • 低风险,默认自动执行,失败后可重试或降级,例如搜索文档、读取代码、查询只读数据、整理摘要
  • 中风险,可自动执行但要留痕,并保留撤销窗口,例如文案修改、批量替换、工作区文件编辑
  • 高风险,执行前必须阻断并等待确认,例如删除文件、外网请求、代码提交、数据库变更、发布和付费接口调用

差别通常不在有没有确认按钮,而在卡片里给不给够决策信息。动作是什么、为什么动、影响范围、能不能撤销、有没有备选,最好一眼能看完,别只剩一句是否继续。

审批如果只在前端拼文案,很快会和真实执行脱节。更省事的做法是把审批收成结构化数据,从后端下发,挂到同一条 trace 上,事件流里推 approval_required,回放、审计和告警都读同一份。

卡片上最好有:

  • 风险等级、审批时限、发起来源一眼可见
  • 受影响资源和变更范围可展开查看,必要时接 Diff
  • 可逆操作提供 Undo 入口和预计回滚成本
  • 支持改参数或切换替代动作后再执行,减少往返沟通
  • 全量记录审批人、审批理由、执行结果,满足审计留痕

审批、trace、状态机和观测如果能共用一套模型,人机协同就不只是打补丁。

Streaming 应该让 Agent 过程可见

流式输出如果只用来更快吐 token,对 Agent 任务帮助有限。用户更想知道现在卡在哪一步、工具在干什么、要不要自己点一下。

事件可以粗分三类,最好走同一条推送通道,省得前端接好几套协议:

  • token 层,持续输出自然语言内容
  • step 层,推送每一步的动作、工具状态和中间结论
  • progress 层,推送总进度、耗时和成本,减少等待焦虑

用一条联合类型把字段钉死,前后端少扯皮。下面是个示意,覆盖状态变化、工具起止、审批、进度和收尾,载体可以用 WebSocketEventSource

type AgentStreamEvent =
  | { type: "state_changed"; state: AgentState; at: number }
  | { type: "token"; text: string; stepId: string }
  | { type: "tool_started"; stepId: string; tool: string; args: unknown }
  | { type: "tool_finished"; stepId: string; ok: boolean; summary: string }
  | { type: "approval_required"; request: ApprovalRequest }
  | { type: "progress"; done: number; total: number; costUsd: number }
  | { type: "final"; answer: string; traceId: string }
  | { type: "error"; message: string; recoverable: boolean };

协议统一以后,时间线、步骤卡片、进度条和审批弹窗才好做,中间态不必全塞进气泡里。界面上比较值得先做的几件事:

  • 步骤折叠与展开,避免长任务刷满屏幕
  • Observation 面板分层展示工具入参、结果摘要、原始返回
  • 工具日志实时滚动,失败步骤高亮并给出重试入口
  • 全局状态浮层显示当前状态机节点与等待原因
  • StopRetryContinue、插话打断与后端取消契约对齐
  • 人工接管入口用于切换执行策略或直接改写下一步
  • 最终答案和中间证据联动,点击引用可回跳对应 step

同样是等三十秒,转圈和看着系统一步步推进,感受差很多。过程可见,用户能更早纠偏,也能少烧不少无效 token

离线评测资产、线上观测与可迁移遥测

Agent 要长期迭代,既要离线侧能证明有没有变好,也要线上侧能看见真实流量里发生了什么,还要让埋点与字段尽量不因换观测后端而推倒重来。这一节把三件事收进一条工程链条:先固定可迁移遥测语义,再让离线评测与回归产出可进闸门的证据,最后在线上仍用同一套字段读 trace、成本、实验与用户反馈。底座语义与线上观测必须同源,否则灰度里对不上离线报表。

image.png

语义底座先把典型 span 名、属性键和事件形状写进约定,常见列包括 trace_idspan_idmodeltoken 进出与 cost_usd 等,并对齐 GenAIOpenTelemetry 社区里已经有人在用的写法。这样换导出器或换观测后端时,主要改连接与映射,业务代码少动字段名。离线评测与回归靠版本化用例集、对结果与格式与合规的断言、与基线的对照统计、接入 CI 的闸门和可计量的回归耗时,把主观手感压成可复跑的 Eval 分数。线上可观测在同一套定义下读 trace 时间线、成本随时间和用量变化、AB 流量拆分、用户情绪与满意线索、以及告警与异常。工程上的收束是:语义先沉淀进离线证据,离线结论再拿去和线上 trace、金丝雀或灰度放量对齐,团队才不会各写各的报表。

落到工具时,离线侧靠版本化用例、对过程与结果的断言、基线对比和接入 CI 的闸门把手感变成证据。promptfooDeepEvalRagas 分别偏配置、断言、指标,关键是同一套用例能从开发跑到发布。线上噪声更大,盯住任务完成率、工具成功与超时、成本与风险侧信号即可。LangfuseLangSmithPhoenixHelicone 选型看能否把 trace、实验、分数和反馈收进同一面板。OpenTelemetryGenAI 语义适合当公共约定,先统一 LLMtoolagent 如何建 span,以及 token、延迟、错误码等字段,迁移成本主要在导出器。

前端加 LangChain 开发者可以重点讲的几点

前端把运行中的系统摊开给人看:状态、步骤、工具、风险、中断重试入口,以及 token 与成本摘要。trace 不应只躺在仓库里,而要变成时间线、Step 卡片、风险高亮和失败回放。模型差不多时,把过程讲清楚往往比再换一次模型更能换来信任和效率。

一个成熟 Agent 项目的技术区分度该怎么描述

重点不是接了哪个新模型,而是能否在真实业务里持续跑稳、可对比、可审计。下面是一段自述示例,可按实际情况改名词和程度。

我做的不是调模型、调工具的 Demo,而是面向真实用户的 Agent 运行系统。LLM 与编排负责生成与流转,Harness、状态机 Loop、风险控制、HITL、可观测和评测负责稳定与可治理。 工程上我会打通离线评测、线上观测和回归闸门,用统一遥测语义串起 trace、成本、质量与用户反馈,让每次迭代可对比、可回放、可审计。 我有前端背景,会把过程可视化、干预入口和体验指标当成主交付物,而不是只交最后一段文本。

总结

RAG 可以做,Agent 也可以做,它们都只是手段,不是终点。真正拉开差距的是你有没有把需求、执行、观测、评测和迭代接成闭环。下面四条自检,有一半答不上来,就值得对照正文里的分层补一补。

  • 执行与韧性:是否有独立运行时与预算约束,Loop 是否显式状态机,故障能否回放,成本与步数是否可解释。
  • 质量与证据:是否有维护中的评测集、CI 或合并前的回归闸门,红队用例是否像测试代码一样可复跑,而不是发版前凭手感点几下。
  • 安全与过程:输入、工具调用前、输出与轨迹四层里,哪些已经落地成策略与埋点,高风险路径是否默认进审批而不是靠运气不触发。
  • 观测与闭环:线上是否能同时看到 trace、成本、实验与用户反馈,离线分数与线上信号能否进同一套界面或同一套数据模型,而不是各团队各一份报表。

能跑通链路只是起点,能不能长期闭环才是标准。

你有没有把它做成一个能稳定运行、可观测、可评测、可干预、还能持续迭代的闭环系统。

走前端加 LangChain 这条线的人,手里正好捏着界面、状态和事件,把这些和模型、工具、观测、评测缝在一起,比单纯多接一个模型更难被模板替代,写进自我介绍里也更有话可说。

HTTP 请求的五种传参方式

作者 花满溪
2026年4月28日 18:25

在 Web 开发中,客户端向服务端传递数据有多种方式。本文介绍五种常见的传参方式,以及它们的适用场景。

为了让读者更好地理解每种传参方式,文中示例均使用 Express 搭建的本地服务器进行演示。你可以在本地启动服务后,通过浏览器或 Postman 实际发送请求,观察服务端的接收效果。

场景对比

传参方式 适用场景 数据类型 大小限制
路径参数 获取确定性资源,如用户信息、商品详情 简单字符串 无限制
查询参数 搜索、过滤、分页 短数据 受 URL 长度限制
x-www-form-urlencoded 传统表单提交 简单键值对 较小
application/json RESTful API 调用 复杂嵌套结构 较大
multipart/form-data 文件上传、混合数据提交 文件 + 表单

一、链接传参 - 路径参数

在 URL 路径中传递参数。例如:/users/{id}

// 示例:GET 请求
http://localhost:3000/users/123

服务端通过路径占位符获取参数 id=123


二、链接传参 - 查询参数

在 URL 查询字符串中传递参数。查询参数是问号 ? 后的键值对,多个参数用 & 连接。

// 示例:GET 请求
http://localhost:3000/search?keyword=node&page=2

服务端获取 keyword=nodepage=2


三、请求体传参 - application/x-www-form-urlencoded

POST 请求,请求头 Content-Type 设置为 application/x-www-form-urlencoded

HTML 表单使用 method="post" 时,默认使用这种编码方式。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/register" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

提交后,浏览器将表单数据编码为 username=admin&password=123456,发送到后端。


四、请求体传参 - application/json

POST 请求,请求头 Content-Type 设置为 application/json

这种方式需要使用 JavaScript(如 fetch)发送请求。

<!DOCTYPE html>
<html>
<body>
    <script>
        fetch('http://localhost:3000/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                username: "admin",
                password: "123456",
            })
        });
    </script>
</body>
</html>

请求体内容为 JSON 字符串:{"username":"admin","password":"123456"}


五、请求体传参 - multipart/form-data

POST 请求,请求头 Content-Type 设置为 multipart/form-data

这种方式通常用于文件上传,也可以同时提交普通表单字段。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/upload" method="post" enctype="multipart/form-data">
        <input type="file" accept=".jpg" name="avatar">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

浏览器自动生成请求头:Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO1HGmt23sYZeHuMf

boundary 后面的字符串是分隔符,用于区分不同的字段。

请求体格式示例:

------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"
Content-Type: image/jpeg

[文件二进制数据]
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="password"

123456
------WebKitFormBoundaryO1HGmt23sYZeHuMf--

每个字段由 boundary 分隔,字段名和字段值之间有一个空行。


六、本地服务器

以下是一个 Express 服务器实现,集成了上述五种传参方式的处理逻辑。启动服务后,你可以配合前文的 HTML 页面或 Postman 进行测试。

环境准备

npm init -y
npm i express cors multer

服务端代码

const express = require('express');
const cors = require('cors');
const multer = require('multer');
const app = express();
const port = 3000;
app.use(cors());

// 1. 处理路由参数(URL 路径中的参数)
// 示例:GET /users/123
app.get('/users/:id', (req, res) => {
    const userId = req.params.id;
    res.json({
        message: `获取用户信息`,
        userId: userId
    });
});

// 2. 处理 URL 查询参数(Query String)
// 示例:GET /search?keyword=node&page=2
app.get('/search', (req, res) => {
    const { keyword, page } = req.query;
    res.json({
        message: '查询参数已接收',
        keyword: keyword,
        page: page || 1
    });
});

// 3. 处理表单提交的 URL 编码参数
app.use(express.urlencoded({ extended: true }));

// 示例:POST /register,表单数据
app.post('/register', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 4. 处理 POST 请求的 JSON 参数(请求体)
// 需要配置 JSON 解析中间件
app.use(express.json());

// 示例:POST /login,请求体 {"username": "admin", "password": "123345"}
app.post('/login', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 5.处理单文件上传
// 配置 multer(内存存储,文件以 Buffer 形式存在)
const upload = multer({ storage: multer.memoryStorage() });

app.post('/upload', upload.single('avatar'), (req, res) => {
    // req.file 包含上传的文件信息
    console.log('文件信息:', req.file);
    console.log('文本字段:', req.body);

    res.json({
        message: '文件上传成功',
        filename: req.file.originalname,
        size: req.file.size,
        mimetype: req.file.mimetype
    });
});

// 启动服务
app.listen(port, () => {
    console.log(`start:http://localhost:${port}`);
});

启动与测试

运行 node server.js 启动服务后,可以:

  • 访问前文的 HTML 页面,直接在浏览器中发送请求
  • 使用 Postman 或 curl 手动构造请求,观察响应结果
  • 查看终端日志,确认服务端是否正确接收参数
昨天以前首页

把 Sa-Token 搬到 NestJS 生态:xlt-token 1.0 的几个设计取舍

2026年4月26日 18:11

最近发布了 xlt-token@1.0.0-rc.1,一个为 NestJS 设计的 Token 鉴权库,灵感来自 Java 生态的 Sa-Token

仓库:github.com/xiaoLangtou…

功能列表看起来不复杂——登录、登出、踢人、权限校验、会话存储——但动手实现时,每个"理应如此"的能力背后都有几个不那么显然的选择。这篇文章想聊聊其中几个,主要是为了自己复盘,也希望对做类似设计的人有参考价值。


为什么不直接用 Passport?

@nestjs/passport 几乎是 NestJS 鉴权的默认答案,但它本质上是个 strategy 调度器——你提供策略(local / jwt / oauth2),它负责调度。它不解决的问题包括:

同账号在第二台设备登录时,第一台应该被踢还是共存?用户被踢下线后,前端拿到 401,怎么区分"token 过期"和"管理员强制下线"?用户连续操作 24 小时不该被踢,但闲置 30 分钟应该自动登出——这两种过期机制怎么同时支持?除了 loginId,还想存最近 IP、设备 ID 等数据,且与 token 同生命周期。

这些是业务侧每次都要重新发明的轮子。Sa-Token 在 Java 生态把它们统一封装好了,我希望 Node 生态也有类似的东西。

但移植不是翻译。Java 的同步阻塞模型、Spring 的注解扫描、JVM 的反射,在 TypeScript 里都得重新设计。下面几个细节就是这种"重新设计"过程中最典型的取舍。


存储键的三层结构

最朴素的方案是 token -> userId 一对一映射:

auth:token:abc123 → "1001"

但这没法实现顶号。你拿到的是新登录的 userId=1001,不知道这个用户之前用的是哪个 token,要找到它只能遍历所有 key,性能上不可接受。

xlt-token 的方案是三层键空间:

authorization:login:token:<token>          → loginId
authorization:login:session:<loginId>      → token
authorization:login:lastActive:<token>timestamp

有了反向索引,登录时的顶号逻辑就是两次 O(1) 的 store 操作:

async login(loginId: string) {
  const oldToken = await store.get(sessionKey(loginId));
  if (oldToken && !isConcurrent) {
    await store.update(tokenKey(oldToken), 'BE_REPLACED');
  }
  const newToken = strategy.create();
  await store.set(tokenKey(newToken), loginId);
  await store.set(sessionKey(loginId), newToken);
  return newToken;
}

后续加上权限和会话后键空间又扩展了几条,但接口契约不变:所有键都是平铺的字符串 KV,可以无差别接到 Memory / Redis / 任何 KV 存储上。


为什么踢人不能删 Key

用户被踢下线时,直觉是直接删掉 tokenKey:

async kickout(loginId) {
  const token = await store.get(sessionKey(loginId));
  await store.delete(tokenKey(token));
  await store.delete(sessionKey(loginId));
}

问题在于,用户下次带着旧 token 来请求,store.get(tokenKey) 返回 null,你没法区分"被踢了"和"token 过期了"——前端只能收到一个通用的 401。

xlt-token 的做法是写哨兵值而不是删除:

async kickout(loginId) {
  const token = await store.get(sessionKey(loginId));
  await store.update(tokenKey(token), 'KICK_OUT');  // 保留 TTL,只改值
  await store.delete(sessionKey(loginId));
}

请求到来时,_resolveLoginId 按顺序判定:token 不存在、值为 null、值为 BE_REPLACED、值为 KICK_OUT、活跃过期、通过——最终前端拿到的 401 响应体可以精确区分六种未登录原因,客户端可以针对每种情况做不同处理("账号在其他设备登录"和"已被强制下线"是两种截然不同的用户体验)。

哨兵值的 TTL 跟着原 token 的剩余时间走,不会造成内存泄漏。代价是踢人时多写了一条数据,但读场景的诊断精度提升显著。


权限通配符匹配:两个 Bug

P1 加权限校验时要支持 user:* 匹配 user:add / user:edit。第一版写出了这样的代码:

function matchPermission(pattern: string, target: string): boolean {
  pattern.split('').forEach((char, i) => {
    if (char === '*') return true;
    if (char !== target[i]) return false;
  });
  return true;
}

forEach 回调里的 return 只结束当次回调,不结束外层函数,所以任何输入都返回 true,权限校验形同虚设。改用正则:

export function matchPermission(pattern: string, target: string): boolean {
  if (pattern === target) return true;
  if (pattern === '*') return true;

  const regex = new RegExp(
    '^' + pattern.replace(/[.+?^${}()|[]\]/g, '\$&').replace(/*/g, '.*') + '$'
  );
  return regex.test(target);
}

第二个 bug 藏得更深。权限引擎里有这样的"短路优化":

async hasPermission(loginId: string, perm: string) {
  const list = await this.stpInterface.getPermissionList(loginId);
  if (list.includes(perm)) return true;
  return list.some(p => matchPermission(p, perm));
}

list.includes 是全等匹配。如果用户拥有 ['user:*'],校验 'user:add' 时,includes 返回 false,才会走到 some(...) 里的通配符匹配——这条路径是对的。但这段"short-circuit"代码本身掩盖了一个事实:includes 不是 matchPermission 的子集,两者语义不同。一旦业务逻辑稍微复杂一点(比如同时有精确权限和通配符权限),这条快路径就可能产生意料之外的行为,而且很难从测试中察觉,因为两条路径独立通过。

最终我把这个短路优化删掉了,性能损失不到 5%(权限列表通常 10~50 项),但逻辑变得线性可推理。


Guard 抽象类里的死代码

NestJS Guard 做鉴权后通常要把用户信息加载到 request.userxlt-token 为此提供了一个抽象基类:

@Injectable()
export abstract class XltAbstractLoginGuard implements CanActivate {
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    if (!this.requiresLogin(ctx)) return true;

    const request = ctx.switchToHttp().getRequest();
    const result = await this.stpLogic.checkLogin(request);

    if (!result.ok) {
      await this.onAuthFail?.(result, request);
      throw new NotLoginException(result.reason);
    }

    request.stpLoginId = result.loginId;
    await this.onAuthSuccess?.(result, request);
    return true;
  }

  protected requiresLogin(ctx: ExecutionContext): boolean { /* 默认实现 */ }
  protected onAuthSuccess?(result, request): void | Promise<void>;
  protected onAuthFail?(result, request): void | Promise<void>;
}

业务子类只需实现 onAuthSuccess 加载用户信息。看起来很干净——单元测试全绿,提了 PR。

E2E 测试时发现 onAuthFail 永远没有被调用过。回看代码才发现:stpLogic.checkLogin 内部在校验失败时会直接抛出 NotLoginException,所以 if (!result.ok) 这条分支是死代码,onAuthFail 钩子永远到不了。

修复方式是把钩子塞进 catch:

async canActivate(ctx) {
  let result;
  try {
    result = await this.stpLogic.checkLogin(request);
  } catch (e) {
    if (e instanceof NotLoginException) {
      await this.onAuthFail?.({ ok: false, reason: e.message }, request);
    }
    throw e;
  }
}

这个 bug 用单元测试发现不了,因为单元测试通常会 mock checkLogin,让它返回一个 { ok: false } 对象而不是真的抛错。只有把整个 Guard 放进真实 Nest 容器里跑 E2E,才会暴露钩子从来没被触发过这件事。这之后我给项目补了完整的 E2E 测试基建。


StpUtil 静态门面 vs DI

NestJS 最佳实践是一切走 DI,但有些场景 DI 很不方便:全局异常过滤器、工具类 Helper、测试中需要快速 mock 全局认证状态。参考 Sa-Token,xlt-token 同时提供了静态门面:

import { StpUtil } from 'xlt-token';

const token = await StpUtil.login('1001');
const id = await StpUtil.getLoginId(req);

实现是个延迟单例,XltTokenModuleOnModuleInit 时把容器里的实例注入静态变量。两种风格的主要差异:DI 方式可测试性更好、天然支持多实例;静态门面使用更便捷,但是全局单例且必须在 Module init 之后才能调用。

两者并存是有意为之,让用户在不同上下文按习惯选择,底层实现是同一套,行为一致。


数据

1.0.0-rc.1 打包后 gzip 7.4 KB,单测覆盖率 98%+,E2E 覆盖率 95%+,195 个测试用例。依赖只有 es-toolkituuid 和 NestJS peer dep,没有任何 ORM / DB / Redis 强绑定。


未来

1.0 的范畴是完备的单点登录鉴权。1.1.0 计划补齐:二级认证 + 临时 token、多端登录管理(按设备类型互踢)、JWT Strategy(与当前 UUID 策略互切换)、在线用户列表等观测性 API。

详细 Roadmap:xiaolangtou.github.io/xlt-token/r…


pnpm add xlt-token@next

文档:xiaolangtou.github.io/xlt-token
GitHub:github.com/xiaoLangtou…

1.0.0-rc.1 是发稳定版前的最后窗口期,欢迎 API 命名、类型签名、文档方面的反馈,或者直接开 Issue。

在职前端 Agent 配置分享

作者 菠萝的蜜
2026年4月26日 20:59

前言

去年花了半年时间对公司旧业务代码做了不少架构优化,今年开始陆续就要开始业务开发了。

不得不说在 AI 时代背景下,开发范式每天都在变化,prompt engineering -> context engineering -> agent engineering -> harness engineering,一路狂飙,看似每天都有新东西要学习,到最后大多都是 FOMO。

然而在显而易见的不确定性面前,总有一些东西是固定不变的。今天我来分享在 AI 冲击下我的前端 Agent 开发配置,这些内容个人认为属于长期不变的地基。

(本文以 Mac 为例)

基本工具

首先是两个配置工具:

  1. cc-switch
  2. skills.sh

前者用于接入不同 AI 供应商,例如业内熟知的 Claude、Codex、Gemini、OpenCode 等等;后者用来添加 skills,一些固定的工作流被总结为技能供模型识别和调用。

CC Switch

安装

以 Homebrew(macOS)为例:

brew tap farion1231/ccswitch
brew install --cask cc-switch

# 更新
brew upgrade --cask cc-switch

其他平台也可以在 Release 找到对应的安装包。

image.png

更新

APP 的关于页可以检查更新、同时还兼具了本地环境检查:

image.png

我觉得特别好的一点就是还提供了一键安装的脚本:

image.png

以往我都是要去官方文档上找,这里一键复制更方便。

设置 Skills 存储位置

打开「设置 > 通用」面板,修改如下:

image.png

默认情况下,Skills 被存储在 ~/.cc-switch/skills/ 下,换成 ~/.agents/skills/,因为 skills.sh 的脚本安装的 skills 默认也是后者,这遵循了 Agent Skills 开放标准,很多 Agent 都能主动发现此处的 skills。

设置自动故障转移

打开「设置 > 路由」面板,依次打开本地路由、自动故障转移的开关:

image.png

image.png

回到 APP 首页打开两个开关:

image.png

下面可以选择需要加入的服务,按照优先级每次使用 cc 时会先用高优先级的,出现熔断就会退到下一级:

image.png

开启用量查询

以 kimi code 为例,点击列表中某项的用量查询:

image.png

在预设模板中找到合适的配置:

image.png

回到首页可以看到能够自动查询用量了:

image.png

点击顶部图标也能看到用量:

image.png

Skills 和 MCP

Skills 和 MCP 在右上角:

image.png

Skills

Skills 管理面板中,比较常用的是「导入已有」,然后点亮需要加入该 skill 的 Agent 工具:

image.png

点击「发现技能」可以搜索开头我们提到的 skills.sh 中的技能,下面以 code-simplifier 为例:

image.png

点击安装就能加入 Skills 列表。

这和在 skills.sh 上获取安装指令是一样的:

image.png

需要注意的是,使用命令行安装时默认安装到 ~/.agents/skills/ 下,想要同时支持 cc,就要自己勾选:

npx skills add https://github.com/ant-design/ant-design-cli --skill antd

image.png

通用的放全局:

image.png

安装方式推荐使用 Symlink:

image.png

安装完成后,命令行输入 claude 打开 cc,看到如下界面说明 CC Switch 的配置是有效的,一定要选 Yes,否则会让登录官方的账号:

image.png

输入 /skills 可以看到 antd skill 确实被安装进来了:

image.png

gemini、codex、opencode 也有:

image.png

image.png

image.png

刚刚安装列表中的 kimi 也有:

image.png

而未主动选择的 kilo 没有该技能:

image.png

在 Skills 管理中可以导入已有技能:

image.png

注意:由于 codex、gemini、opencode 都能从 ~/.agents/skills/ 下读取到技能,即便在 CC Switch 中取消引用,还是能搜到;cc 从 ~/.claude/skills/ 下读取技能,关闭了引用就搜不到了。

image.png

总结一下,如果按照我说的修改了 Skills 存储位置,那么想要 cc 加入对应 skill 就打开对应 skill 开关,其他剩下几个 Agent 开不开都可以。

MCP

MCP 服务器管理面板没有 Skills 那么复杂,且功能类似,大家可以自己研究下。

(其实是不想写了,哈哈)

image.png

Skills、MCP 推荐

Skills、MCP 都装了不少,但是本着如无必要、勿增实体的原则,最低限度推荐以下几个:

Skills:

MCP:

LangGraph 使用指南

作者 remember_me
2026年4月24日 18:22

LangGraph 使用指南

基础概念

LangGraph 是一个用于构建有状态、多步骤 AI 工作流的框架,基于 LangChain 构建,核心概念包括:

  • Graph(图):工作流的整体结构,由节点和边组成
  • Node(节点):工作流中的处理步骤,可以是函数、LLM 调用或任何可执行逻辑
  • Edge(边):连接节点的路径,定义执行顺序
  • State(状态):在节点之间传递的共享数据对象
  • Compile(编译):将图转换为可执行对象

安装方法

# 基础安装
pip install langgraph

# 如果使用 LangChain 模型
pip install langchain langchain-openai

# 可选:用于可视化
pip install matplotlib

核心功能

1. 构建基本图结构

from typing import TypedDict, List
from langgraph.graph import StateGraph, END

# 定义状态类型
class State(TypedDict):
    messages: List[str]
    count: int

# 定义节点函数
def node1(state: State) -> State:
    state["messages"].append("Node 1 executed")
    state["count"] += 1
    return state

def node2(state: State) -> State:
    state["messages"].append("Node 2 executed")
    state["count"] += 1
    return state

# 创建图
graph = StateGraph(State)

# 添加节点
graph.add_node("step1", node1)
graph.add_node("step2", node2)

# 添加边
graph.set_entry_point("step1")
graph.add_edge("step1", "step2")
graph.set_finish_point("step2")

# 编译图
app = graph.compile()

# 执行
result = app.invoke({"messages": [], "count": 0})
print(result)

2. 条件边(条件路由)

from langgraph.graph import StateGraph, END

class State(TypedDict):
    input_text: str
    category: str

def classify(state: State) -> State:
    # 模拟分类逻辑
    if "?" in state["input_text"]:
        state["category"] = "question"
    else:
        state["category"] = "statement"
    return state

def handle_question(state: State) -> State:
    state["input_text"] = f"Answer to: {state['input_text']}"
    return state

def handle_statement(state: State) -> State:
    state["input_text"] = f"Processed statement: {state['input_text']}"
    return state

# 条件路由函数
def decide_category(state: State) -> str:
    if state["category"] == "question":
        return "question_node"
    return "statement_node"

# 构建图
graph = StateGraph(State)
graph.add_node("classify", classify)
graph.add_node("question_node", handle_question)
graph.add_node("statement_node", handle_statement)

graph.set_entry_point("classify")
graph.add_conditional_edges(
    "classify",
    decide_category,
    {
        "question_node": "question_node",
        "statement_node": "statement_node"
    }
)
graph.add_edge("question_node", END)
graph.add_edge("statement_node", END)

app = graph.compile()

result = app.invoke({"input_text": "What is LangGraph?", "category": ""})
print(result)

3. 循环和递归

class State(TypedDict):
    count: int
    max_count: int
    result: str

def increment(state: State) -> State:
    state["count"] += 1
    state["result"] = f"Step {state['count']}"
    return state

def should_continue(state: State) -> str:
    if state["count"] < state["max_count"]:
        return "increment"
    return "end"

graph = StateGraph(State)
graph.add_node("increment", increment)

graph.set_entry_point("increment")
graph.add_conditional_edges(
    "increment",
    should_continue,
    {"increment": "increment", "end": END}
)

app = graph.compile()
result = app.invoke({"count": 0, "max_count": 3, "result": ""})
print(result)

4. 集成 LLM(以 OpenAI 为例)

from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage
from langgraph.graph import StateGraph, END

class State(TypedDict):
    query: str
    response: str

def call_llm(state: State) -> State:
    llm = ChatOpenAI(temperature=0)
    messages = [HumanMessage(content=state["query"])]
    state["response"] = llm.invoke(messages).content
    return state

graph = StateGraph(State)
graph.add_node("llm_call", call_llm)
graph.set_entry_point("llm_call")
graph.set_finish_point("llm_call")

app = graph.compile()
result = app.invoke({"query": "What is the capital of France?", "response": ""})
print(result["response"])

最佳实践

1. 状态管理最佳实践

# 使用 TypedDict 确保类型安全
from typing import TypedDict, Optional, List

class ChatState(TypedDict):
    messages: List[dict]
    user_id: str
    session_data: Optional[dict]
    error: Optional[str]

2. 错误处理

def safe_node(state: State) -> State:
    try:
        # 业务逻辑
        result = process_data(state)
        return {"...": result}
    except Exception as e:
        state["error"] = str(e)
        return state

# 添加错误处理路径
def check_error(state: State) -> str:
    return "error_handler" if state.get("error") else "next_node"

3. 性能优化

# 使用缓存避免重复计算
from functools import lru_cache

@lru_cache(maxsize=100)
def expensive_computation(input_data: str) -> str:
    # 耗时操作
    return processed_result

def node_with_cache(state: State) -> State:
    state["result"] = expensive_computation(state["input"])
    return state

4. 可观测性

# 添加日志记录
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def monitored_node(state: State) -> State:
    logger.info(f"Processing state: {state}")
    result = process(state)
    logger.info(f"Result: {result}")
    return result

5. 测试策略

# 单元测试节点
def test_node():
    state = {"input": "test", "output": ""}
    result = my_node(state)
    assert result["output"] == "expected_output"
    
# 集成测试整个图
def test_graph():
    app = build_graph()
    result = app.invoke({"input": "test"})
    assert "output" in result

6. 常见陷阱避免

避免在节点内部修改共享状态

# ❌ 错误做法
def bad_node(state: State) -> State:
    state["shared_data"].append("value")  # 直接修改
    return state

# ✅ 正确做法
def good_node(state: State) -> State:
    new_state = state.copy()
    new_state["shared_data"] = state["shared_data"] + ["value"]
    return new_state

完整示例:问答系统

from typing import TypedDict, List, Optional
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage

class QnAState(TypedDict):
    question: str
    context: Optional[str]
    answer: str
    confidence: float
    needs_clarification: bool

def validate_question(state: QnAState) -> QnAState:
    """验证问题是否有效"""
    if not state["question"] or len(state["question"]) < 3:
        state["needs_clarification"] = True
    return state

def handle_clarification(state: QnAState) -> QnAState:
    """处理需要澄清的问题"""
    state["answer"] = "Please provide a more specific question."
    return state

def retrieve_context(state: QnAState) -> QnAState:
    """检索相关上下文(模拟)"""
    # 实际中会从数据库或文档中检索
    state["context"] = f"Context related to: {state['question']}"
    return state

def generate_answer(state: QnAState) -> QnAState:
    """使用 LLM 生成答案"""
    llm = ChatOpenAI(temperature=0.7)
    system_msg = SystemMessage(content="Answer the question accurately.")
    human_msg = HumanMessage(content=f"Context: {state.get('context', 'No context')}\n\nQuestion: {state['question']}")
    response = llm.invoke([system_msg, human_msg])
    state["answer"] = response.content
    state["confidence"] = 0.9 if state.get("context") else 0.5
    return state

def decide_path(state: QnAState) -> str:
    """条件路由决策"""
    if state["needs_clarification"]:
        return "clarification"
    return "answer_generation"

# 构建图
graph = StateGraph(QnAState)
graph.add_node("validate", validate_question)
graph.add_node("clarification", handle_clarification)
graph.add_node("retrieval", retrieve_context)
graph.add_node("answer_generation", generate_answer)

graph.set_entry_point("validate")

# 条件边
graph.add_conditional_edges(
    "validate",
    decide_path,
    {
        "clarification": "clarification",
        "answer_generation": "retrieval"
    }
)

# 顺序边
graph.add_edge("retrieval", "answer_generation")
graph.add_edge("clarification", END)
graph.add_edge("answer_generation", END)

app = graph.compile()

# 执行
result = app.invoke({
    "question": "What is machine learning?",
    "context": None,
    "answer": "",
    "confidence": 0.0,
    "needs_clarification": False
})

print(f"Answer: {result['answer']}")
print(f"Confidence: {result['confidence']}")

进阶技巧

  1. 并行执行:使用 add_parallel_edges 实现并行节点
  2. 子图:创建可复用的子图模块
  3. 状态持久化:配合数据库实现长期状态存储
  4. 流式输出:使用 stream 方法实现实时输出

LangGraph 的强大之处在于将复杂的 AI 工作流抽象为有向图,使代码更清晰、可维护且易于调试。开始构建你的第一个图形化 AI 应用吧!

面试官:LangChain中 TS 和 Python 版本有什么差别,什么时候选TS ❓❓❓

作者 Moment
2026年4月23日 09:23

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

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

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

image.png

很多人一上来就问 LangChain.js 到底能不能和 Python 版打,其实这个问题放在 2025 年已经不太成立了。现在的 LangChain.js 和 Python 版早就不是一个能做、一个不能做的关系,而是生态重心、运行时环境、团队技能结构上的差别。官方两边都在围绕 createAgentcreate_agentmiddlewareLangGraph 这套 agent runtime 推进,核心能力的差距相比早期缩小了很多。真正值得讨论的是,在你自己的项目里到底该选哪一边。

核心能力已经很接近

先从能力层面看。两边都提供了生产可用的 agent 入口,Python 侧是 create_agent,JS/TS 侧是 createAgent。两边都把 middleware 作为核心定制机制,用来做上下文工程、摘要、PII 处理、人类审批、工具控制、状态管理这些事。JS 侧的 createAgent 底层基于 LangGraph 的 graph-based runtime,和 Python 侧走的是同一条架构路线。换句话说,做工具调用 agent、工作流 agent、带状态和中间控制的 agent,两边都能胜任,你不会因为选了 TS 就被卡在某个能力边界上。

生态厚度的差别在集成数量上

能力差不多,但生态厚度差得比较明显。官方集成页上 Python 侧的可用集成是 1000+,JS/TS 侧是 100 多个,差了将近一个数量级。

20260422172323

20260422172355

这个差距短期内不会被抹平,因为 Python 天然连着更大的 AI 和数据生态。数据清洗、文本处理、embedding 前处理、实验脚本、评测、离线任务,这些事情在 Python 里通常都有现成的库和现成的示例可以直接抄。你遇到一个冷门的向量库、冷门的模型提供商、冷门的文档解析器,在 Python 侧大概率能找到适配,在 JS 侧可能就要自己包一层。

反过来看,JS/TS 的优势不在集成数量上,而在离 Web 产品更近。如果你本来就在 Node 里做 API、SSE、WebSocket、前后端共享 schema、全栈 monorepo,那用 TS 会让系统边界更简单,省掉大量跨语言胶水代码。

TS 的真正价值是全栈一致

沿着上面这点继续往下说。选 TS 的最大好处其实不是 AI 能力更强,而是能把整个系统打通成一套类型。前端表单类型、后端 DTO、工具入参 schema、Zod 校验、agent 输出结构、SSE 返回类型,甚至日志事件类型,都可以放在同一套类型系统里管。这种一致性在 Python 和 TS 混用的架构里很难做到,常常要靠文档约定或者手写 schema 对齐,维护起来很累。

Node 项目里,middleware 这类运行时控制也更容易直接融进现有的 Web 服务,不用额外起一个 Python 子服务再做接口拼接。对做产品的团队来说,这种工程上的顺滑感,往往比多几百个集成更重要。

两边都更规范但仍在快速演进

说完优势再说一下稳定性。LangChain 目前按 semver 管理版本,minor 加新特性,patch 高频修 bug。和 0.x 时期相比稳定了很多,不再是动不动就改 API 的状态。但这个领域整体仍在快速变化,不管你选 Python 还是 TS,做生产项目都要锁版本,不要无脑追最新。这一点两边是一样的,不构成选型差异。

什么时候优先选 TS

把前面几点串起来看,就能得到一个比较清楚的选型判断。下面这几类场景选 TS 会更顺。

  • 在做 AI 产品,不是在做 AI 研究。比如聊天、文档编辑 agent、知识库问答、工作流编排、客服后台、内容生成后台这类偏产品交付的系统。
  • 主栈已经是 Next.jsNestJS 或者纯 Node,用 TS 能减少语言切换、减少服务拆分、减少跨语言 schema 漂移。
  • 特别在意类型安全和契约一致性,工具参数、结构化输出、前后端共享类型、Zod 校验这些需求都希望一套语言搞定。
  • 要把 AI 能力直接嵌进现有 Web 服务,比如 SSE 流式输出、实时 UI、在线编辑器、业务鉴权、BFF 层整合。

什么时候反而该选 Python

反过来,下面这几类场景选 Python 更省事。

  • 大量文档 ETL、离线索引、数据实验、批处理。
  • 高度依赖更广的第三方 AI、检索、数据生态,需要用到很多冷门集成。
  • 团队里 AI 工程师以 Python 为主,notebook 和实验迭代是主工作流。
  • 经常要找社区现成示例,希望命中率更高。

这两组判断背后的事实基础其实是同一个,就是 1000+ 和 100+ 这个集成数量差,决定了两边在不同场景下的顺手程度。

混合架构通常是更稳的落地方式

在真实项目里,很多团队不是非此即彼,而是两边都用。尤其是做 Next.jsNestJS 加编辑器 Agent 产品的团队,第一选择可以是 TS,但不代表全链路都得 TS。因为你真正要解决的问题不是做最前沿的算法实验,而是下面这几件事。

  • 怎么把 agent 接到产品里
  • 怎么和编辑器、接口、鉴权、队列、流式返回结合
  • 怎么把 schema、状态、工具调用、前后端契约统一起来

这些问题上 TS 比 Python 省很多系统复杂度。但一旦涉及重 ETL、重索引、重离线处理,用 TS 去硬啃生态空缺反而不划算。这时候比较实用的做法是把链路拆成两层。

20260422173256

前台产品层和在线 agent 层用 TS,负责直接面向用户的实时请求。重 ETL、重索引、重离线处理的 worker 单独上 Python,吃 Python 那边的生态红利。两条链路通过消息队列或者存储层解耦,互相不干扰。这种架构通常比一开始全 Python 或者强行全 TS 都更稳。

总结

回到最初那个问题。LangChain.js 和 Python 版今天已经站在同一条架构路线上,核心 agent 能力都够用,真正的差别在生态厚度和运行时环境。Python 胜在 1000+ 集成和更厚的 AI 数据生态,JS/TS 胜在和 Web 产品栈的天然贴合以及一套类型贯穿全栈的工程体验。

所以最后给你的结论是这样。做 Web 产品、编辑器、SaaS、Agent 平台这类偏产品交付的系统,优先 TS。做数据实验、检索管线、研究型系统这类偏数据和研究的工作,优先 Python。如果两头都要做,就按用户实时链路和离线数据链路拆开,让 TS 和 Python 各司其职。这样既能吃到 TS 在产品工程上的顺滑,也能吃到 Python 在 AI 生态上的厚度,不用二选一。

Hermes Agent 直连飞书机器人

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

适用场景:

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

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

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

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

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

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

  1. 创建应用

  1. 添加机器人能力

  1. 开通对应权限

注意要打开的权限:

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

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

  1. 创建版本,发布应用

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

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

2. Hermes连接飞书

  1. Windows 上先处理编码

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

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

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

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

3. 启动飞书接入向导

hermes gateway setup

选择 Feishu / Lark

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

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

  • Scan QR code to create a new bot automatically

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

接着会出现两个选项:

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

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

  1. 选择 Connection mode

建议选:

  • WebSocket

原因:

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

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

  1. Direct messages authorization

建议选:

  • Use DM pairing approval

这表示:

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

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

  1. Group chats handling

建议选:

  • Respond only when @mentioned in groups

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

  1. Home chat ID

这个可以先留空。

它只影响:

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

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

  1. 回车完成

3. 验证

4. 切换模型

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

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

  1. 执行以下命令

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

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

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

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

hermes config show

正确的结果会包含:

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

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

或者:

/new

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

5. 结论

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

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

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

验证是否成功:

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

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

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

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

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

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

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

把 Claude Design 做成 Skill,你的网站也能拥有顶级视觉体验

作者 ConardLi
2026年4月22日 19:55

本文视频: www.bilibili.com/video/BV1Mb…

一、Claude Design 是什么?

2026 年 4 月 17 日,Anthropic 发布了 Claude Design。

这个产品上线当天,Figma 股价大跌。

Claude Design 由 Opus 4.7 驱动,提供给 Pro、Max、Team、Enterprise 订阅者使用。

它的界面很简洁:左边聊天,右边画布。

你描述你想要什么,它就在右边画出来。

你可以通过对话、行内评论、直接编辑、甚至它自动生成的调节滑杆去反复修改。

听起来像一个 "AI 版 Figma" ?

Anthropic 设计团队的 Ryan Mather 发推说了一句关键的话:

不要用对待画布工具的方式来用 Claude Design,它更像 Claude Code。

这句话是理解 Claude Design 的关键。

传统设计工具的逻辑是 — 人在画布上操作,AI 辅助加速

Claude Design 反过来 — AI 是主要的生成者,人是主要的审阅者

这个区别落到产品上,产生了几个具体差异。

第一,输出是可运行的代码。

链接可以点,标签可以切,版本可以 DIFF。这跟 "生成一张漂亮的 UI 图" 完全是两码事。

第二,它理解你的代码库。

你上传代码库之后,它会读组件结构、框架模式、文件组织。

第三,它会主动提问、给多方案、自检自纠。

整套协作模式跟以前任何设计工具都不一样。

Ryan Mather 一个人同时负责 Anthropic 的 7 条产品线。这在两个月前不可能。

有团队反馈说,以前在别的工具里需要 20 多轮提示才能搞定的复杂交互,在 Claude Design 里 2 轮就搞定了。

用这个工具和直接用 Claude Code 编写网页有啥区别呢?

其实关键的变量还是:提示词工程

Claude Design 之所以强,一半归功于 Opus 4.7 的模型能力,另一半归功于那套精心设计的系统提示词。

Claude Design 发布不到 24 小时,安全大佬 Pliny the Liberator 就在 X 分享它的完整系统提示词。

接下来我们就来仔细拆解这套提示词,到底有哪些是我们值得借鉴的地方。


二、拆解 Claude Design 的系统提示词

Claude Design 的系统提示词大约 420 行,信息密度极高。我们把里面最核心、最有价值的设计理念提炼出来,逐一解读。

2.1 角色定位:设计师 + 工匠 + 产品经理

You are an expert designer working with the user as a manager. You produce design artifacts on behalf of the user using HTML.

开头第一句,定义了一个非常精妙的协作关系:AI 是设计师,用户是产品经理

注意它没有说 "你是一个 AI 助手"。它说 "你是一个专家设计师,用户是你的经理"。

这个微妙的角色倒转带来两个效果:AI 会更主动地做决策(设计师该有的判断力),同时会在关键节点征求用户意见(向经理汇报)。

紧接着下一句也很关键:

HTML is your tool, but your medium and output format vary. You must embody an expert in that domain: animator, UX designer, slide designer, prototyper, etc.

HTML 是工具,但你的身份要随任务切换。做动画时你是动效设计师,做原型时你是 UX 设计师,做幻灯片时你是 Deck 设计师。这意味着 AI 不会用"做网页"的思维去做一切——比如不会给幻灯片加导航栏,不会给动画加页脚。

很多人写提示词时,角色定义停留在 "你是一个前端开发者"。

Claude Design 告诉我们:好的角色定位应该是动态的 — 根据具体任务切换专业身份。


2.2 工作流:先问后做,尽早出活

Claude Design 定义了一套六步流程:

  1. 理解需求(问清楚再动手)
  2. 探索现有资源
  3. 制定计划
  4. 搭建文件结构
  5. 完成并验证
  6. 极简总结

这里面有两个值得注意的细节。

第一个:什么时候问,什么时候直接做?

提示词明确给出了判断标准:

  • make a deck for the attached PRD → ask questions
  • make a deck with this PRD for Eng All Hands, 10 minutes → no questions; enough info was provided
  • turn this screenshot into an interactive prototype → ask questions only if intended behavior is unclear from images

这套判断逻辑背后的原则是:信息充足就干活,信息不足才提问

这听起来很常识,但绝大多数提示词做不到这一点——要么永远先问一大堆,要么从不提问直接硬干。

Claude Design 还给出了提问的具体建议:至少问 10 个问题、提供多选和自由输入的选项、覆盖变体方向、视觉偏好等。

第二个:极简总结的要求。

Summarize EXTREMELY BRIEFLY — caveats and next steps only.

只说注意事项和下一步,不赘述自己做了什么。

这个原则同样精辟 — 代码已经在那里了,你为什么还要用文字复述一遍你做了什么?


2.3 去除 AI 味的秘诀

这是整份提示词中我认为最有价值的部分之一。

Claude Design 给出了一份明确的 "避免 AI 味的清单":

  • 不要过度使用渐变背景
  • 不要随便使用 Emoij
  • 不要用带左侧彩色边框的圆角卡片
  • 不要用 SVG 硬画复杂图形(用占位符,索取真实素材)
  • 不要用烂大街的字体:Inter、Roboto、Arial、Fraunces、system-ui
  • 不要堆砌无意义的数据和图标("data slop")

这份清单堪称一面照妖镜。

回想一下你见过的 AI 生成网页 — 是不是清一色的紫粉蓝渐变、Inter 字体、大圆角卡片、emoji 充当图标、假数据满天飞?

这些元素单独看都没什么问题,但当 AI 反复使用它们,就形成了一种强烈的"AI 味"。

就像一篇文章通篇 "此外"、"值得注意的是"、"综上所述"、"不是xxx、而是 xxx",你一眼就知道是 AI 写的。

Claude Design 通过显式列出这些雷区,强迫 AI 跳出舒适区。

与之配套的还有一个字体推荐表——重点放在 "你绝对不要用什么字体",同时提供更好的替代选择。

比如推荐 Plus Jakarta Sans、Space Grotesk、Sora、Newsreader 等相对小众但品质出色的字体。

2.4 oklch 色彩系统:有理论支撑的配色策略

Color usage: try to use colors from brand / design system, if you have one. If it's too restrictive, use oklch to define harmonious colors that match the existing palette. Avoid inventing new colors from scratch.

提示词的配色策略分三层:

  1. 优先用品牌色
  2. 不够用时,用 oklch 色彩空间派生和谐的衍生色
  3. 绝对不要凭空发明新的色相

为什么是 oklch?

传统的 HSL 色彩空间有个致命缺陷:感知不均匀

同样的亮度值,黄色看起来比蓝色亮得多。

这导致 AI 用 HSL 随机生成的配色经常看起来不和谐——技术上"正确",视觉上却刺眼。

oklch 是一个感知均匀的色彩空间

在 oklch 中,相同的亮度值代表人眼感受到的相同亮度。

这意味着你只需要保持亮度(L)和色度(C)不变,改变色相角(h),就能自动得到和谐的色彩搭配。


2.5 内容原则:"一千个 No 换一个 Yes"

Do not add filler content. Never pad a design with placeholder text, dummy sections, or informational material just to fill space. Every element should earn its place.

One thousand no's for every yes.

这句话出自乔布斯。用在这里恰如其分。

AI 做设计时有一个根深蒂固的倾向:填满空间

给它做一个落地页,它恨不得塞进 hero、特性介绍、客户评价、数据展示、FAQ、联系方式 …… 全都有,但是全都很平庸。

Claude Design 的应对策略很直接:

  • 每个元素必须证明自己存在的理由
  • 想加内容?先问用户
  • 页面看起来空?那是布局问题,用版式和留白解决,不是靠塞内容

这背后的设计哲学是:留白也是设计。一个大胆的留白,比十个平庸的板块更有表现力。


2.6 验证闭环:不信任自己的输出

Claude Design 的验证机制非常严谨:

  1. 完成后调用 done,展示给用户并检查控制台错误
  2. 如果有错误,修复后再次调用 done
  3. 确认无误后,调用 fork_verifier_agent — 启动一个独立的子代理,在自己的 iframe 里做全面检查(截图、布局、JS 探测)

注意第三步:它会 fork 出另一个独立的 Agent 来做检查,避免 "自己审自己" 的确认偏误。

这里和我们之前分享过的 Claude Code 在 Harness 上的实践有同样的理念。

AI 检查自己的输出时,容易陷入"确认偏误" — 它倾向于认为自己做得没问题。

用一个全新的上下文来做验证,能有效打破这种偏误。


2.7 其他亮点速览

上下文管理: 提示词引入了 snip 工具,允许 AI 标记已完成的对话段落为"可删除"。当上下文压力增大时,这些段落会被移除,腾出空间。这解决了长对话中上下文窗口溢出的问题 — AI 会主动 "忘掉" 不再需要的信息。

PPT编号规范: 提示词特别强调PPT编号必须是 1-indexed(从 1 开始),并解释原因 — "人类不会说第 0 张幻灯片"。这个细节看似琐碎,但它反映了一个重要原则:AI 的输出应该适配人类的思维模型,而非机器的实现细节

设计系统先行: 在写第一行代码之前,AI 必须先定义设计系统 — 配色方案、字体、间距、圆角、阴影、动效风格。把这些 Token 化的决策前置,能有效保持全局一致性。这也是专业设计师的标准做法。

Tweaks 面板: 这是一个聪明的 UI 模式 — 在设计中内置一个可调参面板,让用户实时切换配色、字体、间距等参数。它把"选择"的权力交给用户,同时降低了沟通成本。


三、把 Claude Design 的精华提炼成 Skill

3.1 为什么要做这个 Skill?

Claude Design 是 Anthropic 的商业产品,基于 Opus 4.7 驱动,功能强大,但是有几个问题:

  • 国内用户使用困难(最大的问题,花园老师已经被封了三个账号了,彻底放弃官方渠道了...)。
  • 没有 API,你无法把它集成到自己的工作流中。
  • 封闭性,你不能自定义它的行为逻辑。

但它的系统提示词暴露了一个事实:

Claude Design 的核心竞争力,很大程度上来自那套精心设计的 Prompt Engineering

Opus 4.7 的模型能力是基础,但让它稳定输出高水平设计的,是那 420 行提示词。

这意味着我们可以做一件事:把这套提示词的核心理念提炼出来,封装成一个可复用的 Skill,应用到 Codex、Curosr 或其他支持 SKILL 的工具中。

这就是 web-design-engineer Skill 的由来。


3.2 Skill 的设计思路

我的 Skill 约 400 行,结构如下:

1. 角色定义

此技能将 Agent 定位为顶尖的设计工程师,可以创造优雅、精致的 Web 作品。
核心理念:目标直指"惊艳",远超"能用"的底线。每个像素都有意义,每个交互都经过深思熟虑。

继承了 Claude Design 的动态角色切换思路 — 根据任务自动变身为 UX 设计师、动效设计师、数据可视化专家。

2. 六步工作流(改良版)

我在 Claude Design 的基础上做了调整:

步骤 Claude Design 原版 我的 Skill
第一步 理解需求 理解需求(附带详细的提问/不提问场景判断表)
第二步 探索资源 获取设计上下文(分四个优先级)
第三步 计划 动手前先宣告设计系统(要求用 Markdown 列出所有设计决策)
第四步 搭建文件 尽早展示 v0 半成品(带假设和占位符的最小可展示版本)
第五步 完成 完整构建
第六步 总结 验证

重点说说第三步和第四步。

第三步:宣告设计系统。

Claude Design 的提示词里提到了设计系统,但没有强制要求在编码前用自然语言宣告出来。

我加了这个约束。

原因很简单:如果 AI 在脑子里默默决定了配色方案然后开始写代码,你第一次看到的就是一个完整的页面。

此时如果方向错了,推翻重来的成本很高。提前宣告出来,用户可以在动手前就纠偏。


第四步:v0 半成品策略。

灵感同样来自 Claude Design 的 "show file to the user early" 理念,但我把它提炼成了更明确的方法论。

v0 包含核心结构 + 配色字体 token + 关键模块占位,不包含内容细节和完整组件库。

带假设和占位符的 v0,比花 3 倍时间打磨出来的 "完美 v1" 更有价值 — 后者一旦方向错了就要全推翻。


3. 反 AI 味清淡(扩展版)

在 Claude Design 的基础上,我补充了更多条目:

  • 千篇一律的渐变按钮 + 大圆角卡片组合
  • 凭空编造的客户 logo 墙、虚假的好评数
  • 无意义的 stats / 数字 / 图标堆砌

同时明确了 emoji 的使用规范:

默认不用 emoji。只有当目标设计系统/品牌本身就用 emoji 时才使用。

没图标时用占位符 — 拿 emoji 当 icon 替身是一种敷衍。


4. 占位符哲学

这是我在 Claude Design 基础上提炼出的一个完整方法论:

没有图标、图片、组件时,画占位符比硬画假图更专业。
- 图标缺失 → 方块 + 标签(如 [icon]、▢)
- 头像缺失 → 首字母圆形色块
- 图片缺失 → 带 ratio 信息的占位卡
- Logo 缺失 → 品牌名文字 + 简单几何形

占位符传递的信号是"这里需要真材料"。
假图传递的信号是"我糊弄完了"。

5. 配色 × 字体配对参考表

这是完全新增的内容。我提供了几套经过验证的视觉风格起点:

风格 主色 字体组合 适用场景
优雅杂志风 oklch 暖棕 Newsreader + Outfit 内容平台、博客
高端品牌 oklch 近黑 Sora + Plus Jakarta Sans 奢侈品、咨询
活泼消费 oklch 珊瑚 Plus Jakarta Sans + Outfit 电商、社交
极简专业 oklch 青蓝 Outfit + Space Grotesk 数据产品、B2B
手作温度 oklch 焦糖 Caveat + Newsreader 餐饮、教育

这些配对的核心逻辑是:给 AI 一个有品位的起点,比让它自由发挥好得多。

当用户没有提供任何设计上下文时,AI 从这些经验证的组合出发,比默认的 Inter + 蓝色按钮好上几个档次。


6. 技术硬规则

继承了 Claude Design 的三条绝对不能违反的规则:

  1. 禁止 const styles = {...}——多组件环境中的全局样式对象命名冲突是个真实的坑。
  2. 跨 babel 脚本不共享作用域——必须显式挂到 window 上。
  3. 禁止 scrollIntoView — 在 iframe 嵌入环境中会破坏外部滚动。

这些都是被真实 bug 验证过的规则,每一条都有具体的技术原因。


7. 附赠的高级模式库

Skill 附带一个 references/advanced-patterns.md 文件,包含:

  • 响应式幻灯片引擎模板
  • 设备模拟框架(iPhone / 浏览器窗口)
  • 动画时间线引擎
  • 设计画布(多方案对比)
  • oklch 色彩系统代码
  • Chart.js 数据可视化模板

这些模板的定位类似于 Claude Design 的 copy_starter_component — 给 AI 提供高质量的起点脚手架,而非让它从零开始手搓。

四、实战对比:Skill 到底带来了多少提升?

说了这么多理论,到底有没有用?

我用同一个提示词,分别在有 Skill 和没有 Skill 的情况下生成了两个页面,来看看实际效果。

注:以下测试使用的环境是 Cursor + Claude Opus 4.7


4.1 DEMO 1:太空探索博物馆

提示词(完全相同):

帮我做一个 "太空探索博物馆" 的线上展览首页。这是一个虚构的博物馆,专门展示人类太空探索的里程碑。页面需要包含:一个震撼的全屏 Hero 区域、4 个核心展览介绍(如 "登月时代"、"空间站生活"、"火星计划"、"深空探索")、一个时间线展示人类太空探索大事记(至少 6 个节点)、参观预约 CTA 区域、页脚。整体风格要沉浸感强、有宇宙的深邃感。

无 Skill 版本

模型(Claude Opus 4.7)本身已经很强。

无 Skill 版本用了 Orbitron + Noto Serif SC 的字体组合,有 Canvas 星空背景、CSS 绘制的行星、轨道环、流星动画。

整体观感已经很不错了 — 深色背景、发光效果、渐变文字,太空感是有的。

但仔细看,有几个典型的"AI 设计"痕迹:

  1. 配色偏向 AI 默认审美。 青色(#7cf0ff)、紫色(#b388ff)、粉色(#ff7ad4)这套三色渐变组合,在 AI 生成的太空主题网页中非常常见。不是不好看,是太常见了。
  2. 字体选择偏传统。 Orbitron 是一个非常"直觉"的太空字体选择 — 几乎所有 AI 做太空主题都会用它。辨识度不够。

  1. 布局相对标准化。 Hero → 卡片网格 → 时间线 → CTA → 页脚,是一个教科书式的落地页结构。安全,但缺少惊喜。
  2. 视觉效果靠堆砌。 大量 box-shadow 发光效果、多层渐变背景、CSS 行星动画 — 视觉冲击有,但设计感不够克制。

有 Skill 版本

带 Skill 的版本,差异在几个层面上体现出来。

配色体系完全不同。 用了 oklch 色彩空间定义整套色彩系统 — var(--ink)oklch(0.10 0.015 250)var(--ember)oklch(0.78 0.13 65)。整个色彩方案更加克制和高级,没有浓烈的霓虹感,取而代之的是一种接近杂志印刷品质的深沉色调。

字体选择更考究。 Instrument Serif + Space Grotesk + JetBrains Mono 三件套。Instrument Serif 的衬线字做大标题,有杂志编辑质感;Space Grotesk 做正文,技术感但不冰冷;JetBrains Mono 做辅助信息(坐标、编号),自带科技叙事。

排版手法更成熟。 标题用了文字逐行入场动画(rise keyframe),每行有微妙的延迟差。Hero 区域底部有一条信息栏 — 左侧坐标数据、中间描述性文案、右侧行动按钮 — 用 grid 三栏布局分配。这种编辑感的排版方式很明显受到了高端杂志和设计工作室网站的影响。

细节更精致。 导航栏 logo 是三个同心圆 + 一个 ember 色小圆点(用 CSS ::before::after 实现),有天体轨道的隐喻。页面右上角有一组实时坐标数据(ISS — LIVE TELEMETRY),作为装饰性信息元素。整个页面用 clamp() 做流式排版,间距全部用 CSS 自定义属性管理。

最关键的区别:克制感。 无 Skill 版本的设计语言是"把所有酷炫的效果都用上"。有 Skill 版本的设计语言是"每个元素都经过取舍"。前者像一个热情的初级设计师,后者像一个有经验的设计总监。

4.2 DEMO 2:独立摄影师个人网站

第二个测试更能说明问题,因为提示词非常简洁:

帮我做一个独立摄影师的个人作品集网站首页。

仅此一句。这考验的是 AI 在信息极度匮乏时的自主决策能力。

无 Skill 版本

首先是无 Skill 版本,发完提示词就直接开干了:

这次没有了足够的背景信息,虽然效果还不错,但 AI 味就有点重了:

  1. 烂大街的深色霓虹调性 背景用纯黑或深灰,再用青蓝色或紫粉色的高饱和度发光阴影勾勒边框和文字。这是 AI 最常用的手段。

  1. 卡片和导航栏大量使用半透明背景。

  1. 文案里强行塞满“潜空间、创成式、算力、硅基、参数”等词汇,看起来高端但缺乏人味,失去了摄影师原本“慢、真、自由”的情感内核”的情感连接情感”的真实质感。

有 Skill 版本

有 Skill 版本最首要的区别就是它没有直接开干,而是向我询问了几个关键问题:

然后再看看最终实现的效果:

它虚构了一个名为 Mira Høst 的北欧摄影师,为她设计了一整套视觉系统:

  • 配色: 暖色纸张底色(--paper: #f2efe8)+ 深色墨色(--ink: #161513)。整个色盘只有两个极端加上它们之间的灰调,极度克制。

  • 字体: Instrument Serif(展示标题)+ Space Grotesk(界面元素)。Instrument Serif 的斜体被大量用于营造优雅感。

  • 布局语言: 杂志编排式的。左右两侧有固定的竖排文字("Issue 07 · MMXXV" 和 "© Mira Høst Studio"),用 mix-blend-mode: difference 确保在深浅背景上都可读。

  • Hero 处理: 使用 Unsplash 的风景照片,叠加了电影感的渐变蒙版和胶片噪点纹理。照片有一个极慢的 Ken Burns 动画(24 秒一个周期),模拟胶片放映机的缓慢推移。

  • 导航设计: 没有传统的导航栏,用了一个 mix-blend-mode: difference 的顶部信息条,在深色 Hero 和浅色正文之间无缝过渡。

整个页面从上到下,有一种翻阅高端摄影画册的感觉。每个板块之间用编号(01、02、03...)和 eyebrow 文字分隔,像杂志的章节标记。照片的展示使用了不对称的网格布局,刻意打破整齐感。


4.3 对比的结论

需要强调的是:无 Skill 版本已经很好了。

Claude Opus 这个级别的模型,裸跑出来的网页也远超多数人的手写水平。

色彩搭配和谐、布局合理、代码规范、交互完整。

Skill 带来的提升,打个比方:无 Skill 版是一个 85 分的好学生作品,有 Skill 版是一个 95 分的设计师作品。

从"好用"到"好看",从 "完整" 到 "精致",从 "合格" 到 "有风格"。

这 10 分的差距,恰恰来自 Skill 里那些看似琐碎的约束和引导 — 不用 Inter、用 oklch 配色、先宣告设计系统、克制内容填充、用高品质字体配对做起点。

每一条规则的效果可能微小。但叠加在一起,量变产生质变。

五、附录

获取 Skill

Skill 的完整代码、原始参考的 Claude Design 的提示词、几个 DEMO 网站,我都已经打包到一起开源了。

https://github.com/ConardLi/web-design-skill

大家需要的自取:github.com/ConardLi/we…


最后

最后推荐下我的 Easy Agent 开源项目。

https://github.com/ConardLi/easy-agent

目标是在这个过程中和大家一起学习 Claude Code 的 Harness 是如何做的,然后从零编写一个 Agent。

最终完整跟下来的同学都能具备从零开发企业级 Agent 的能力。

如果你也在尝试从传统领域到 AI Agent 开发的转型,这个项目不容错过呀 ...

仓库在这:github.com/ConardLi/ea…


文本用到的 Skill、Claude Design 原始提示词、DEMO 网站:github.com/ConardLi/we…

如果本期教程对你有所帮助,来个免费的点赞、收藏、关注吧,后续我会持续分享更多有价值的 AI 教程,感谢你的阅读 💗。

独立开发者主流技术栈(2026最新)

2026年4月22日 11:18

一般情况独立开发者的技术栈核心追求:全栈统一、开发高效、部署简单、成本极低、生态完善,以下按Web、移动端、桌面端、数据库、运维/工具、AI辅助六大维度,整理当前最主流、最实用的技术选型(含热门组合与单类选项)。

一、Web全栈(最主流,SaaS/工具/网站首选)

1. 前端(React系,2026绝对主流)

  • 核心框架Next.js 15(全栈React,App Router+Server Components,一人搞定前后端)
  • 备选框架:Nuxt 3(Vue全栈,上手快)、Remix、SvelteKit
  • 样式方案Tailwind CSS + shadcn/ui(无样式组件+自由定制,开发最快)
  • 备选UI:Ant Design、Material Design、DaisyUI、Chakra UI
  • 状态管理:Zustand、Jotai、Redux Toolkit、Pinia(Vue)
  • 数据请求:TanStack Query(React Query)、SWR、Axios
  • 表单/验证:React Hook Form + Zod、Formik
  • 语言TypeScript(必选,类型安全,减少bug)

2. 后端(全栈JS/Python为主,轻量优先)

  • Node.js生态(最主流)
    • 框架:Express、NestJS(企业级)、Hono(轻量Edge)
    • 全栈:Next.js API Routes/Edge Functions(无需单独后端)
  • Python生态(AI/数据/快速原型)
    • 框架:FastAPI(高性能API)、Flask(极简)、Django(全功能)
  • 备选:Go(Gin,高性能)、Rust(Axum,安全高效)
  • ORMPrisma(全数据库支持,生态最好)、Drizzle(轻量Serverless)

二、移动端(跨平台优先,减少学习成本)

  • 跨平台首选Flutter(Dart,性能接近原生,一套代码双端)
  • Web开发者首选React Native(React语法,复用Web技能)
  • 轻量/小程序转App:UniApp(Vue语法,支持多端+小程序)、Taro
  • 原生(性能极致):Android(Kotlin+Jetpack Compose)、iOS(Swift+SwiftUI)

三、桌面端(跨平台,Web技术复用)

  • 主流Electron(React/Vue+Node,成熟稳定,如VS Code)
  • 新锐轻量Tauri(Rust后端,体积小、性能优)
  • 备选:Qt(C++,跨平台原生)、WPF(Windows原生)

四、数据库(免费+托管优先,减少运维)

1. 关系型(主流)

  • 托管首选Supabase(PostgreSQL,免费500MB,自带认证/存储/实时)
  • 备选托管:Neon、PlanetScale(Serverless MySQL)、Turso(SQLite)
  • 自建:PostgreSQL、MySQL(经典稳定)

2. 非关系型

  • 文档型:MongoDB(托管MongoDB Atlas)
  • 缓存/实时:Redis(托管Upstash)
  • 向量数据库(AI):Milvus、Pinecone、Chroma

五、运维/部署/工具(零成本+自动化)

  • 部署(免费额度足)
    • Web:Vercel(Next.js最佳搭档,一键部署)、Cloudflare Pages
    • Serverless:Cloudflare Workers(免费10万次/天)、Vercel Edge Functions
  • 认证:Supabase Auth、NextAuth.js、Better Auth、Clerk
  • 支付(SaaS必备):Stripe(全球)、PayPal、微信/支付宝(国内)
  • 邮件:Resend(免费3000封/月)、Nodemailer
  • 存储:Cloudflare R2、AWS S3、Supabase Storage
  • 监控/分析:Sentry(错误)、Posthog、Umami、Plausible(用户分析)
  • CI/CD:GitHub Actions(免费)
  • 开发工具:VS Code、Git、Figma(设计)、Postman(API测试)

六、AI辅助(2026必备,效率翻倍)

  • 代码生成:GitHub Copilot、Cursor、Claude Code、Vercel v0(前端UI)
  • AI工具链:LangChain、LlamaIndex(大模型应用)、OpenAI/Anthropic API
  • 设计/素材:Midjourney、DALL·E 3(图片)、Runway(视频)

七、2026独立开发者「黄金技术栈组合」(直接抄作业)

  1. SaaS/Web应用(最强) Next.js 15 + TypeScript + Tailwind + shadcn/ui + Zustand + Supabase + Vercel + Stripe
  2. Vue生态(易上手) Nuxt 3 + Tailwind + Supabase + Prisma + Pinia + Vercel
  3. AI应用 Next.js + FastAPI(Python) + Supabase + Pinecone(向量) + OpenAI API
  4. 移动App Flutter + Supabase + Riverpod(状态)

八、选型核心原则(独立开发必看)

  1. 全栈统一:优先JS/TS(前后端同语言),减少切换成本
  2. 托管优先:不用自建服务器,用Supabase/Vercel等BaaS,零运维
  3. 免费起步:所有工具选有 generous 免费额度的,验证PMF再付费
  4. 生态成熟:选文档全、社区大、坑少的技术,独立开发没时间踩坑
  5. AI赋能:全程用AI工具,代码/设计/文案全流程提效

iOS应用上架全流程:从证书申请到发布避坑指南

2026年4月21日 13:23

iOS上架全流程避坑指南速存!

作为一名独立开发者,今天来和大家分享一下将「楼里」这款应用从iOS打包到上架的全流程。iOS打包到上架,对个人开发者来说就像“九九八十一难”,但只要一步步来,也能顺利完成。下面,我会毫无保留地分享每一个关键步骤。

证书申请篇

  1. 准备一台Mac电脑:这是前提条件,没有Mac的同学可能需要借力或者购买云服务。

  2. 申请苹果开发者账号:费用为688元/年,这是开启iOS开发大门的钥匙。

  3. 证书生成:苹果官方提供了Certificates、Identifiers、Profiles的申请流程,建议自己本地生成.p12私钥证书,这样更安全也更方便后续操作。

此外,使用工具如AppUploader可以在Windows、Linux或Mac系统中直接申请iOS证书,无需依赖Mac电脑,简化证书管理流程。

ICP备案篇

  1. 准备服务器:有免费和付费的选择,根据自己的需求来。

  2. 申请域名:域名价格差异大,好的域名更贵,建议提前规划。

  3. 备案流程:选择App备案,分为初审(平台审)和终审(管局审),正常情况下7天内可以通过。全国互联网安全管理服务平台是备案的重要一环,特别是产品功能基本开发完毕后,审核人员会仔细查看产品。如果App/小程序,还需要线下面签;如果是网站,则可以线上完成。

App打包篇

  1. 使用UniApp开发,打包工具为HBuilderX,这可以大大提高开发效率。

  2. App图标处理:去掉Alpha通道,确保图标显示正常。

  3. 启动界面:创建自定义的storyboard作为启动界面,提升用户体验。

  4. 广告标识:去掉使用广告标识(IDFA)的勾选,保护用户隐私。

  5. 云打包:使用申请的证书文件进行云打包,用回复的链接下载iOS安装包。

发布上架篇

  1. 下载Transporter工具:这是苹果官方提供的安装包交付工具,确保安装包能够顺利提交。

或者使用AppUploader工具上传IPA文件到App Store,支持在Windows、Mac或Linux系统上操作,无需Mac电脑,比Transporter更高效,且能批量上传应用截图和管理描述信息。

  1. 资料准备:准备产品的10张截图、推广文本、描述、关键词等资料,这些都是审核的重要依据。

  2. 图标与截图:App图标大小为1024x1024,直角边,确保在各设备上显示效果最佳。

  3. 隐私政策与技术支持:提供隐私政策网址 (URL) 和技术支持网址 (URL),可以用github或Notion搭建静态网站,方便用户查看。

  4. App供应情况:按情况填需要上架的地区,确保应用能够在目标市场上线。

整个流程下来,虽然复杂,但只要一步步来,每一个细节都处理到位,就能够成功将应用上架到iOS平台。希望今天的分享能够对大家有所帮助。

作为前端,如果使用 Langgraph 实现第一个 Agent

作者 Moment
2026年4月21日 10:37

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

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

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

从这一篇开始,用一个简化版计算器 Agent 走一遍 LangGraph 的核心要素。目标很具体:只用节点、边、状态这三个概念,从零定义一张最小的图、让它真正跑起来,在代码里看清楚状态如何在节点之间流转。持久化、本地服务、子图等进阶内容都留到后面,这一章先让你对图式编排有可运行的手感。

用计算器 Agent 认识图

例子的场景是:用户用自然语言描述算式,比如"请帮我把三加四再乘二",模型理解后决定是否调工具,工具负责加减乘除等具体运算,结果回到模型整理成一句友好的回复。

这个场景不复杂,但很好地覆盖了图的三个关键能力:节点之间如何传递状态、条件边如何根据状态决定下一跳、工具节点执行完后如何回到模型节点继续推理。用图来表示整体执行流程,如下图所示。

20260317080940

模型节点与工具节点之间的回环,就是 LangGraphLangChain 线性链最本质的区别。下面按这个结构一步步把代码写出来。

准备模型与工具

图要跑起来,先得有一个支持工具调用的聊天模型,再配几个简单的计算工具。这部分仍然由 LangChain 提供,LangGraph 暂时不登场。

模型初始化时加了 temperature: 0,是为了让模型在判断"该不该调工具、该调哪个工具"这类结构化决策时输出更稳定,减少随机性带来的不必要波动。三个计算工具 addmultiplydividetool 函数定义,schemazod 写,这样模型拿到工具描述后能清楚知道每个参数的类型。最后调 model.bindTools(tools) 把工具列表注入模型,之后每次调用这个模型时,它就知道手边有哪些工具可用。

import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import * as z from "zod";

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

const add = tool(({ a, b }) => a + b, {
  name: "add",
  description: "Add two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

const multiply = tool(({ a, b }) => a * b, {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => a / b, {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName = {
  [add.name]: add,
  [multiply.name]: multiply,
  [divide.name]: divide,
};

const tools = Object.values(toolsByName);
const modelWithTools = model.bindTools(tools);

到这里模型和工具都准备好了,接下来才是 LangGraph 登场的地方。

定义图的状态

任何一张 LangGraph 图都需要一个状态模式,用来描述在节点之间流转的是哪些数据。状态不是普通对象,每个节点不是整体替换状态,而是只返回需要更新的字段,LangGraph 按字段的 reducer 把更新合并进去。

对于对话类应用,最常用的状态定义是 MessagesAnnotation,它内置了消息列表的 reducer 逻辑。节点每次返回 { messages: [newMessage] },状态系统就自动把这条消息追加到已有列表里,不需要手动维护整个消息数组。

import {
  StateGraph,
  MessagesAnnotation,
  START,
  END,
} from "@langchain/langgraph";

后面定义节点和组装图时都会用到 MessagesAnnotation,它既是状态模式的定义,也给 TypeScript 提供了节点函数参数的类型推断,写 state: typeof MessagesAnnotation.State 就能拿到完整的类型提示。

两个核心节点

这张图里只有两个真正干活的节点。llmCall 负责调用模型,根据当前 messages 生成一条新消息,并判断要不要请求工具。toolNode 根据上一轮模型的工具调用请求执行工具,把结果封装成 ToolMessage 返回。

节点函数可以只接收 state。如果需要流式或回调,可以声明第二个参数 config?: RunnableConfig,图运行时会自动传入。这样 invokestreamEvents 的回调就能一路传到模型和工具里,流式输出才能正常触发。

先写模型节点。它把系统提示和已有消息一起发给模型,只返回本次新生成的那条消息,状态系统负责追加。

import { SystemMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";

const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage(
        "你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"
      ),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

再写工具节点。逻辑分三步:拿到最后一条 AIMessage,根据里面的 tool_calls 逐个执行对应工具,把每个工具的返回值包成 ToolMessage 追加到状态里。tool_call_id 是关键,模型后续要靠它把工具结果和当初的请求对应起来。

import { AIMessage, ToolMessage } from "@langchain/core/messages";

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(
      new ToolMessage({
        content: String(value),
        tool_call_id: toolCall.id ?? "",
      })
    );
  }
  return { messages: results };
};

如果最后一条不是 AIMessage,或者 AIMessage 里没有工具调用,直接返回空列表,图会照常往下走,不会卡住。

条件边与路由

节点准备好之后,还要告诉图跑完某个节点之后下一步去哪。这里的逻辑很清楚:模型回来的消息里如果带着 tool_calls,说明它想用工具,就走到 toolNode;如果没有 tool_calls,说明模型已经可以直接给用户回复了,图结束。

const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  if (lastMessage.tool_calls?.length) return "toolNode";
  return END;
};

这个函数返回的是字符串(节点名)或 ENDLangGraph 拿到返回值后就知道下一步跳到哪个节点。条件边是 LangGraph 表达"分支逻辑"的核心机制,比把 if/else 藏在节点函数里要清晰得多,图的结构一眼就能看懂。

组装并运行整张图

把状态、节点和边用 StateGraph 链式调用串在一起,最后调 compile() 得到可执行的图。addConditionalEdges 的第三个参数是允许到达的节点列表,LangGraph 会在编译时验证条件边函数的返回值不会跳到意外的节点,起到一定的安全检查作用。

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

const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

const result = await agent.invoke({
  messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")],
});

for (const message of result.messages) {
  const content = typeof message.content === "string" ? message.content : "";
  console.log(message.getType(), content);
}

以"请帮我算一下 3 加 4 等于多少"为例,图的完整执行路径如下:

  1. START 进入 llmCall,模型判断需要调 add 工具,返回带 tool_callsAIMessage
  2. shouldContinue 检测到有工具调用,走到 toolNode
  3. toolNode 执行 add(3, 4) 得到 7,包成 ToolMessage 追加到状态,沿固定边回到 llmCall
  4. 模型拿到工具结果,生成"3 加 4 等于 7"这样的自然语言回复,这次没有工具调用,shouldContinue 返回 END,图结束

走完这四步,messages 列表里依次记录了用户消息、模型的工具请求、工具的执行结果、模型的最终回复,完整还原了整条推理过程。

流式输出

invoke 是一次性拿到全部结果,适合脚本和批处理。如果要做"边生成边显示"的体验,用 agent.streamEvents 按事件消费。

import { ChatOpenAI } from "@langchain/openai";
import { tool, type StructuredTool } from "@langchain/core/tools";
import { SystemMessage, AIMessage, ToolMessage, HumanMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import * as z from "zod";

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

// 工具
const add = tool(({ a, b }) => String(a + b), {
  name: "add",
  description: "Add two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const multiply = tool(({ a, b }) => String(a * b), {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => String(a / b), {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName: Record<string, StructuredTool> = {
  add,
  multiply,
  divide,
};

const modelWithTools = model.bindTools(Object.values(toolsByName));

// 节点
const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage("你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return { messages: [] };

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(new ToolMessage({ content: String(value), tool_call_id: toolCall.id ?? "" }));
  }
  return { messages: results };
};

// 条件路由
const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  return lastMessage.tool_calls?.length ? "toolNode" : END;
};

// 组装图
const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

// 流式运行
async function main() {
  const stream = agent.streamEvents(
    { messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")] },
    { version: "v2" }
  );

  for await (const event of stream) {
    if (event.event === "on_chat_model_stream") {
      const chunk = event.data?.chunk?.content;
      if (typeof chunk === "string" && chunk) process.stdout.write(chunk);
    }
    if (event.event === "on_tool_start") {
      console.log(`\n[工具调用] ${event.name}`, JSON.stringify(event.data?.input));
    }
    if (event.event === "on_tool_end") {
      console.log(`[工具结果] ${event.name}: ${event.data?.output}`);
    }
  }

  console.log("\n");
}

main().catch(console.error);

on_chat_model_stream 是模型逐 token 输出,从 event.data.chunk.content 取片段写到终端就能得到打字机效果。on_tool_starton_tool_end 分别在工具开始和结束时触发,可以用来显示"正在计算……"这样的进度提示。这些事件能正常触发的前提是节点里把 config 传给了 modelWithTools.invoket.invoke,回调通路才算打通。如果节点没有传 config,这些事件就不会冒出来。

如果只想按节点观察每一步的状态增量,不关心逐字,可以换成 agent.streamstreamMode: "updates",每个 chunk 就是"节点名 -> 该节点本次返回的状态更新",调试时很有用。

和 LangChain 传统写法的对比

LangChain 写过类似计算器 Agent 的话,会发现两者的逻辑其实差不多,都是模型判断是否需要工具、调用工具、再根据工具结果生成回复。但有几个点上 LangGraph 的优势很明显。

流程可见方面,用 LangChainAgentExecutor,整条执行链路藏在对象内部,从外部很难直接看出走了哪些步骤。用 LangGraph 写,节点、边、条件路由全都显式定义,图的结构就是代码本身,不需要额外文档解释流程。

状态可追方面,LangGraph 图执行过程中,每个节点的输入输出都是状态的一次快照。后面加上 checkpointer 之后,这些快照可以持久化,支持暂停恢复、时间旅行和回放,AgentExecutor 做不到这一点。

扩展性方面,这一章的图很小,只有两个节点。后面加入持久化、人机协同、子图、多 Agent 协作时,只需要在图里加节点和边,不需要重写整个逻辑,扩展起来很自然。

小结

这一章用计算器 Agent 完整走了一遍 LangGraph 的核心三要素,几个值得记住的点:

  • MessagesAnnotation 定义消息列表状态,每个节点只返回增量,框架负责合并,不用手动维护整个数组
  • llmCall 节点负责调用模型,toolNode 节点负责执行工具,两者通过条件边构成一个可以反复循环的 Agent 推理回路
  • shouldContinue 是整张图的路由核心,tool_calls 有值走工具、为空走 END,分支逻辑和节点实现彻底分开
  • streamEvents 打通了逐 token 流式输出和工具事件,前提是节点里把 config 一路传下去,回调通路才能正常工作
  • addConditionalEdges 的第三个参数声明了合法的目标节点,图在编译时会做边界检查,防止条件函数返回意外节点名

下一章会在这张图上引入 checkpointer,给每次执行打快照,为持久化、暂停恢复和时间旅行做准备。

前端重生之 - 前端视角下的 Python

作者 老王以为
2026年4月20日 17:21

以前我认为 JavaScript 就是编程世界的全部。从 jQuery 时代的 DOM 操作,到 React/Vue 的组件化革命,再到 TypeScript 的类型安全,见证了前端技术的每一次跃迁。然而,AI 时代来临,人人都在喊转 “全栈“,所以我也开始真正深入 Python 的生态系统,才发现这不仅是两门语言的对话,更是两种编程哲学、两种技术文化的碰撞与融合。这篇文章,是我从前端视角重新审视 Python 的记录,也是我对技术本质的一次探索,接下来我还将从前端视角看 Java、Go、C# 等不同的后端的语言,可能会有错误的地方,欢迎指正,也欢迎关注我,后期还将有分析其他语言的文章,奥利给!


从 JS 的异步到 Python 的同步

1.1 事件循环的底层机制

前端对事件循环(Event Loop)的理解,往往始于浏览器中的 setTimeoutPromise。JavaScript 的单线程异步模型,是为了应对浏览器环境中用户交互、网络请求等 I/O 密集型场景而设计的。我们习惯了回调地狱的煎熬,也享受过 async/await 带来的语法糖甜蜜。但很少有人深入思考:为什么 JavaScript 必须是单线程的?这个设计选择背后的权衡是什么?

JavaScript 诞生于浏览器环境,而浏览器的核心职责是渲染页面和响应用户交互。如果 JavaScript 是多线程的,一个线程正在修改 DOM,另一个线程同时也在修改同一个 DOM 节点,就会产生竞争条件(Race Condition),导致不可预测的行为。为了避免这种复杂性,JavaScript 的设计者选择了单线程模型,并通过事件循环来实现异步非阻塞 I/O。

浏览器的事件循环可以简化为以下伪代码:

while (true) {
    // 1. 执行宏任务队列中的一个任务
    const macroTask = macroTaskQueue.shift();
    if (macroTask) execute(macroTask);
    
    // 2. 执行所有微任务
    while (microTaskQueue.length > 0) {
        const microTask = microTaskQueue.shift();
        execute(microTask);
    }
    
    // 3. 渲染(如果需要)
    if (shouldRender) render();
}

这个模型保证了 JavaScript 的执行顺序是可预测的:宏任务 → 微任务 → 渲染。Promise 的回调之所以比 setTimeout 先执行,就是因为它们被放入了微任务队列。

然而,当我第一次接触 Python 的 asyncio 时,一种奇妙的熟悉感与陌生感同时涌现。Python 的协程机制与 JavaScript 的 Promise 有着惊人的相似性,但底层哲学却截然不同。

1.2 Python asyncio 的设计哲学

Python 的 asyncio 是在 Python 3.4 中引入的,在 3.5 中通过 async/await 语法得到大幅改进。与 JavaScript 不同,Python 并不是天生单线程的——它有多线程(threading 模块)和多进程(multiprocessing 模块)的完整支持。asyncio 是 Python 对协程(Coroutine)这一并发模型的选择,而不是被迫的设计。

让我们深入对比两者的实现:

# Python asyncio 示例
import asyncio

async def fetch_data(url):
    print(f"开始请求: {url}")
    await asyncio.sleep(1)  # 模拟网络请求
    print(f"请求完成: {url}")
    return f"数据来自 {url}"

async def main():
    # 并发执行多个任务
    tasks = [
        fetch_data("https://api.example.com/1"),
        fetch_data("https://api.example.com/2"),
        fetch_data("https://api.example.com/3")
    ]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())
// JavaScript 对比实现
async function fetchData(url) {
    console.log(`开始请求: ${url}`);
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log(`请求完成: ${url}`);
    return `数据来自 ${url}`;
}

async function main() {
    const tasks = [
        fetchData("https://api.example.com/1"),
        fetchData("https://api.example.com/2"),
        fetchData("https://api.example.com/3")
    ];
    const results = await Promise.all(tasks);
    console.log(results);
}

main();

表面看两者几乎相同,但底层实现有本质区别:

特性 JavaScript Python
事件循环 浏览器/Node 内置,不可替换 asyncio 库实现,可自定义
协程实现 基于 Promise 和微任务队列 基于生成器(Generator)和事件循环
线程模型 单线程 + 事件循环 多线程/多进程 + 可选的协程
GIL 影响 无(天生单线程) 有(多线程受 GIL 限制)
并发性能 适合 I/O 密集型 适合 I/O 密集型,CPU 密集型需用多进程

1.3 GIL:Python 的"阿喀琉斯之踵"

谈到 Python 的并发,就不能不提 GIL(Global Interpreter Lock,全局解释器锁)。GIL 是 CPython 实现中的一个机制,它确保任何时候只有一个线程在执行 Python 字节码。这意味着,即使在多核 CPU 上,Python 的多线程也无法实现真正的并行计算。

import threading
import time

def cpu_bound_task(n):
    """CPU 密集型任务"""
    count = 0
    for i in range(n):
        count += i * i
    return count

# 多线程版本(受 GIL 限制)
def multi_threaded():
    threads = []
    for _ in range(4):
        t = threading.Thread(target=cpu_bound_task, args=(10_000_000,))
        threads.append(t)
        t.start()
    for t in threads:
        t.join()

# 多进程版本(绕过 GIL)
from multiprocessing import Process

def multi_process():
    processes = []
    for _ in range(4):
        p = Process(target=cpu_bound_task, args=(10_000_000,))
        processes.append(p)
        p.start()
    for p in processes:
        p.join()

# 性能对比
start = time.time()
multi_threaded()
print(f"多线程耗时: {time.time() - start:.2f}秒")

start = time.time()
multi_process()
print(f"多进程耗时: {time.time() - start:.2f}秒")

在我的测试环境中(4 核 CPU),多线程版本耗时约 12 秒,而多进程版本仅需 3 秒。这就是 GIL 的影响——多线程在 CPU 密集型任务上无法发挥多核优势。

JavaScript 没有 GIL 的问题,因为它天生就是单线程的。但这也意味着 JavaScript 无法利用多核 CPU 进行并行计算——除非使用 Worker Threads(Node.js)或 Web Workers(浏览器),但这些机制与主线程是隔离的,通信成本较高。

1.4 编程范式的思维转换

JavaScript 是一门多范式语言,但前端开发中函数式编程的影子无处不在:mapfilterreduce 成为日常,Immutable.js 和 Ramda 这样的库广受欢迎。我们追求纯函数、避免副作用、崇尚不可变性。这种趋势在 React 的函数组件和 Hooks 中达到顶峰。

// React 函数组件 + Hooks(函数式风格)
import React, { useState, useEffect } from 'react';

function UserList({ users }) {
    const [filteredUsers, setFilteredUsers] = useState([]);
    
    useEffect(() => {
        const activeUsers = users
            .filter(u => u.isActive)
            .map(u => ({ ...u, name: u.name.toUpperCase() }));
        setFilteredUsers(activeUsers);
    }, [users]);
    
    return (
        <ul>
            {filteredUsers.map(u => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

Python 则是一门 "batteries included" 的语言,它拥抱多种范式却从不偏执。在 Python 中,你可以写出优雅的函数式代码:

# Python 函数式风格
users = [
    {"id": 1, "name": "Alice", "is_active": True},
    {"id": 2, "name": "Bob", "is_active": False},
    {"id": 3, "name": "Charlie", "is_active": True}
]

# 函数式写法
filtered_users = list(
    map(
        lambda u: {**u, "name": u["name"].upper()},
        filter(lambda u: u["is_active"], users)
    )
)

# 但更 Pythonic 的方式是列表推导式
filtered_users = [
    {**u, "name": u["name"].upper()} 
    for u in users 
    if u["is_active"]
]

这种"列表推导式"的语法,是 Python 对函数式编程的本土化改造。它既保留了函数式的表达能力,又符合 Python 简洁优雅的设计哲学。

Python 还支持面向对象和命令式编程:

# Python 面向对象风格
class User:
    def __init__(self, id, name, is_active):
        self.id = id
        self.name = name
        self.is_active = is_active
    
    def activate(self):
        self.is_active = True
    
    def __repr__(self):
        return f"User({self.name})"

# 使用类
users = [User(1, "Alice", True), User(2, "Bob", False)]
for user in users:
    if not user.is_active:
        user.activate()

这让我反思:前端开发中是否过度追求函数式的"纯粹",而忽略了实用主义的平衡?React 的类组件被函数组件取代,但类组件在某些场景下(如复杂的生命周期管理)仍然有其优势。Python 的多范式支持提醒我们:没有最好的范式,只有最适合场景的范式。


类型系统——从动态到静态的考虑

2.1 TypeScript 的革命

2012 年,TypeScript 的诞生改变了前端开发的格局。作为 JavaScript 的超集,TypeScript 为动态语言带来了静态类型的严谨。今天,几乎所有大型前端项目都采用 TypeScript,类型安全已成为行业标准。

TypeScript 的成功不是偶然的。它解决了 JavaScript 开发中的几个核心痛点:

  1. 运行时错误前置:在编译阶段发现类型错误,而不是在生产环境崩溃
  2. IDE 支持:智能提示、自动补全、重构支持
  3. 文档即代码:类型定义就是最好的 API 文档
  4. 团队协作:类型约束作为团队间的契约
// TypeScript 示例
interface User {
    id: number;
    name: string;
    email?: string;  // 可选属性
}

function greet(user: User): string {
    return `Hello, ${user.name}`;
}

// 编译错误:类型不匹配
const result = greet({ id: "1", name: "Alice" });  // Error: id 应该是 number

2.2 Python 类型注解的演进

有趣的是,Python 的类型注解(Type Hints)几乎是与 TypeScript 同期发展的。PEP 484 在 2014 年引入类型注解,PEP 526 在 2016 年完善变量注解。两条平行线,却走向了相似的终点。

from typing import Optional, List, Dict

class User:
    def __init__(self, id: int, name: str, email: Optional[str] = None):
        self.id = id
        self.name = name
        self.email = email

def greet(user: User) -> str:
    return f"Hello, {user.name}"

# 类型检查工具(如 mypy)会在静态分析时报告错误
user = User(id="1", name="Alice")  # mypy: Argument "id" has incompatible type "str"; expected "int"

然而,TypeScript 和 Python 类型系统的底层哲学存在本质差异:

2.2.1 编译时 vs 运行时

TypeScript 的类型在编译时完全擦除,编译后的 JavaScript 不包含任何类型信息:

// TypeScript 源码
function add(a: number, b: number): number {
    return a + b;
}

// 编译后的 JavaScript
function add(a, b) {
    return a + b;
}

Python 的类型注解在运行时保留,但解释器不做强制检查:

# Python 源码
def add(a: int, b: int) -> int:
    return a + b

# 运行时可以通过 __annotations__ 访问类型信息
print(add.__annotations__)  # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

# 但解释器不会检查类型
result = add("hello", "world")  # 正常运行,返回 "helloworld"

2.2.2 结构类型 vs 名义类型

TypeScript 采用结构类型系统(Structural Typing),也称为"鸭子类型"(Duck Typing):

interface Point {
    x: number;
    y: number;
}

function printPoint(p: Point) {
    console.log(`${p.x}, ${p.y}`);
}

// 只要结构匹配,就可以传递
printPoint({ x: 1, y: 2 });  // OK
printPoint({ x: 1, y: 2, z: 3 });  // OK(多余属性允许)

Python 的类型检查器(如 mypy)同样支持结构类型,通过 Protocol(PEP 544):

from typing import Protocol

class Point(Protocol):
    x: int
    y: int

def print_point(p: Point) -> None:
    print(f"{p.x}, {p.y}")

class MyPoint:
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

# MyPoint 没有显式继承 Point,但结构匹配即可
print_point(MyPoint(1, 2))  # OK

2.2.3 类型推断

TypeScript 的类型推断更为激进:

// TypeScript 能推断出 arr 是 number[]
const arr = [1, 2, 3];

// 能推断出 result 是 number
const result = arr.map(x => x * 2).filter(x => x > 2);

Python 的类型推断相对保守,需要显式注解:

from typing import List

# Python 需要显式类型注解
arr: List[int] = [1, 2, 3]

# 或者让 mypy 推断(有限支持)
result = [x * 2 for x in arr if x > 2]  # mypy 能推断为 List[int]

2.3 渐进式类型的价值

这种对比从侧面来说:类型系统的价值不在于"正确性"本身,而在于它如何帮助团队协作和代码演进。Python 的渐进式类型(Gradual Typing)策略——允许在需要时添加类型,在灵活时保持动态——或许比 TypeScript 的"全有或全无"更加务实。

# Python 渐进式类型示例
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models import User  # 仅在类型检查时导入

def process_user(user):  # 动态类型,灵活
    return user.name.upper()

def process_user_typed(user: "User") -> str:  # 静态类型,安全
    return user.name.upper()

在实际的开发中,我通常采用以下策略:

  • 公共 API 和核心模块使用完整的类型注解
  • 脚本和原型代码保持动态类型,快速迭代
  • 使用 mypy 在 CI 中检查关键模块的类型安全

生态系统

3.1 npm 与 PyPI:包管理的两种哲学

前端对于 npm 生态的复杂情感,可以用一句话概括:"node_modules 是世界上最重的东西"。JavaScript 的微包文化(left-pad 事件)和依赖,是每个前端的心头痛。

让我们看看一个典型的 React 项目的依赖树:

$ npm list | wc -l
# 输出可能超过 1000 行

$ du -sh node_modules
# 输出可能超过 500MB

这种依赖膨胀的原因是多方面的:

  1. 微包文化:JavaScript 生态倾向于将功能拆分为极小的包,一个左填充函数(left-pad)也能成为一个包
  2. 重复依赖:不同版本的同一个库可能同时存在
  3. 开发依赖混杂:构建工具、测试框架、类型定义都混在一起

2016 年的微包事件是一个标志性案例。一个只有 11 行代码的包被作者从 npm 下架,导致全球数千个项目无法构建。这暴露了微包文化的脆弱性。

Python 的包管理生态则呈现出不同的面貌。pip、conda、poetry、pipenv …… 工具超级多,但核心理念一致:显式优于隐式

# Python 的 requirements.txt
requests==2.28.1
numpy>=1.21.0
pandas~=1.5.0

这个文件明确告诉我们:

  • requests 必须严格等于 2.28.1 版本
  • numpy 可以是 1.21.0 或更高版本
  • pandas 可以是 1.5.x 系列(补丁版本可以变)

这种显式依赖管理的文化,让 Python 项目的可重现性远超 JavaScript。当你克隆一个 Python 项目,你知道需要安装什么;而当你克隆一个 Node.js 项目,node_modules 的深渊往往让人望而却步。

3.2 虚拟环境:Python 的隔离艺术

Python 的虚拟环境(virtualenv/venv)是包管理的另一大特色。每个项目可以有独立的 Python 环境和依赖,互不干扰。

# 创建虚拟环境
python -m venv myproject-env

# 激活虚拟环境
source myproject-env/bin/activate  # Linux/Mac
myproject-env\Scripts\activate  # Windows

# 安装依赖
pip install -r requirements.txt

# 退出虚拟环境
deactivate

这与 Node.js 的 node_modules 本地安装有相似之处,但更加彻底——虚拟环境甚至隔离了 Python 解释器本身。

现在 Python 项目更倾向于使用 pyproject.toml(PEP 518)来管理依赖:

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "my-project"
version = "0.1.0"
description = "A sample project"

[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
pydantic = "^1.10"

[tool.poetry.dev-dependencies]
pytest = "^7.0"
black = "^22.0"
mypy = "^0.991"

Poetry 不仅管理依赖,还管理虚拟环境、打包、发布,是一个完整的项目管理工具。这与 JavaScript 生态中 npm/yarn/pnpm 的竞争格局形成鲜明对比。

3.3 标准库的力量

Python 的 "batteries included" 哲学,在标准库中体现得淋漓尽致。从文件处理到网络编程,从正则表达式到 JSON 解析,从单元测试到并发编程——Python 标准库几乎覆盖了一个开发者 80% 的日常需求。

# Python 标准库示例
import json
import re
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
from unittest import TestCase, main

# JSON 处理
data = {"name": "Alice", "age": 30}
json_str = json.dumps(data, indent=2)

# 正则表达式
pattern = r"\b\w+@\w+\.\w+\b"
emails = re.findall(pattern, "Contact: alice@example.com, bob@test.org")

# 文件路径操作
config_path = Path.home() / ".config" / "myapp" / "settings.json"

# 日期时间
now = datetime.now()
future = now + timedelta(days=7)

# HTTP 请求
with urllib.request.urlopen("https://api.example.com/data") as response:
    data = json.loads(response.read())

相比之下,JavaScript 的标准库堪称贫瘠。直到 ES6 引入 Promise、fetch、模块化,JavaScript 才勉强跟上时代。但即便如此,lodash、axios、moment 依然是大多数项目的标配。

这种差异的根源在于语言的设计目标:

  • JavaScript 诞生于浏览器,被设计为轻量级脚本语言,依赖浏览器提供的 DOM API
  • Python 诞生于通用编程,被设计为"可执行的伪代码",需要在各种环境中独立运行

标准库的丰富程度,反映的是语言设计者对"开箱即用"的不同理解。

3.4 生态系统的成熟度对比

维度 JavaScript/npm Python/PyPI
包数量 200万+ 40万+
包平均大小 小(微包文化) 大(功能完整)
依赖管理 嵌套依赖(node_modules) 扁平依赖 + 虚拟环境
标准库 贫瘠 丰富("batteries included")
类型定义 @types/* 包 内置类型注解
安全审计 npm audit safety, pip-audit
私有仓库 Verdaccio, Nexus PyPI Enterprise, Devpi

数据科学的疆域的追赶

4.1 Python 的数据霸权

如果说前端是 JavaScript 的天下,那么数据科学就是 Python 的帝国。NumPy、Pandas、Matplotlib、Scikit-learn、TensorFlow、PyTorch——这些库构成了数据科学的完整工具链,而 Python 是它们的通用语言。

这种霸权不是偶然的。Python 的简洁语法、丰富的科学计算库、与 C/C++/Fortran 的良好互操作性,使其成为数据科学家的首选语言。

4.1.1 NumPy:向量化计算的威力

NumPy 是 Python 科学计算的基础。它提供了高效的多维数组对象和数学函数库,底层使用 C 实现,性能远超纯 Python。

import numpy as np

# 创建数组
arr = np.array([1, 2, 3, 4, 5])

# 向量化运算——比 Python 循环快 100 倍
result = arr * 2 + 1  # [3, 5, 7, 9, 11]

# 多维数组
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

# 矩阵运算
transposed = matrix.T
dot_product = matrix @ matrix.T

# 统计函数
mean = np.mean(arr)
std = np.std(arr)
max_val = np.max(matrix, axis=0)  # 每列的最大值

让我们做一个性能对比:

import time

# Python 原生列表
python_list = list(range(1_000_000))

start = time.time()
result = [x * 2 for x in python_list]
print(f"Python 列表推导式: {time.time() - start:.4f}秒")

# NumPy 数组
numpy_array = np.array(python_list)

start = time.time()
result = numpy_array * 2
print(f"NumPy 向量化运算: {time.time() - start:.4f}秒")

在我的电脑上,Python 列表推导式耗时约 0.08 秒,而 NumPy 仅需 0.001 秒——80 倍的性能差距!这是因为 NumPy 的运算是在 C 层面执行的,避免了 Python 的解释器开销。

4.1.2 Pandas:数据处理的艺术

如果说 NumPy 是数组计算的利器,Pandas 就是数据处理的瑞士军刀。它提供了 DataFrame 和 Series 两种数据结构,让数据清洗、转换、分析变得异常简单。

import pandas as pd

# 读取数据
df = pd.read_csv('sales_data.csv')

# 数据清洗
df = df.dropna()  # 删除缺失值
df = df[df['price'] > 0]  # 过滤异常值
df['date'] = pd.to_datetime(df['date'])  # 类型转换

# 数据转换
df['revenue'] = df['price'] * df['quantity']
df['month'] = df['date'].dt.month

# 分组聚合
monthly_sales = df.groupby('month').agg({
    'revenue': 'sum',
    'quantity': 'mean'
}).reset_index()

# 透视表
pivot = df.pivot_table(
    values='revenue',
    index='category',
    columns='month',
    aggfunc='sum'
)

# 合并数据
merged = pd.merge(df, customer_df, on='customer_id', how='left')

这段代码如果用 JavaScript 实现,需要多少行?lodash 可以处理数组,但没有原生的 DataFrame 概念。D3.js 可以做数据转换,但学习曲线陡峭。Pandas 的链式操作让复杂的数据处理变得可读、可维护。

4.1.3 数据可视化

Matplotlib 和 Seaborn 是 Python 数据可视化的主力军:

import matplotlib.pyplot as plt
import seaborn as sns

# 设置样式
sns.set_style("whitegrid")

# 创建图表
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# 折线图
df.groupby('date')['revenue'].sum().plot(ax=axes[0, 0], title='Daily Revenue')

# 柱状图
df['category'].value_counts().plot(kind='bar', ax=axes[0, 1], title='Category Distribution')

# 散点图
axes[1, 0].scatter(df['price'], df['quantity'], alpha=0.5)
axes[1, 0].set_title('Price vs Quantity')

# 热力图
corr = df[['price', 'quantity', 'revenue']].corr()
sns.heatmap(corr, annot=True, ax=axes[1, 1], title='Correlation Matrix')

plt.tight_layout()
plt.savefig('analysis.png', dpi=300)

前端可能会说:"这些用 D3.js 也能做,而且交互性更强。"没错,D3.js 的交互能力是 Matplotlib 无法比拟的。但 Matplotlib 的优势在于快速探索和静态报告——数据分析不需要为每个图表写 200 行 D3 代码。

4.2 前端的数据觉醒

幸运的是,前端世界正在觉醒。TensorFlow.js、ONNX.js、Apache Arrow JS——这些项目正在把数据科学的能力带入浏览器。

4.2.1 TensorFlow.js

TensorFlow.js 让机器学习模型可以在浏览器中运行:

import * as tf from '@tensorflow/tfjs';

// 创建一个简单的神经网络
const model = tf.sequential({
    layers: [
        tf.layers.dense({ inputShape: [784], units: 32, activation: 'relu' }),
        tf.layers.dense({ units: 10, activation: 'softmax' })
    ]
});

model.compile({
    optimizer: 'adam',
    loss: 'categoricalCrossentropy',
    metrics: ['accuracy']
});

// 训练模型(在浏览器中)
await model.fit(xs, ys, {
    epochs: 10,
    batchSize: 32,
    callbacks: {
        onEpochEnd: (epoch, logs) => {
            console.log(`Epoch ${epoch}: loss = ${logs.loss}`);
        }
    }
});

// 预测
const prediction = model.predict(newImage);

这意味着前端可以在用户设备上运行机器学习模型,无需服务器参与。隐私保护、低延迟、离线可用——这些是服务端推理无法比拟的优势。

4.2.2 Apache Arrow JS

Apache Arrow 是一种跨语言的列式内存格式,Arrow JS 让 JavaScript 可以高效地处理大规模数据:

import { Table, FloatVector } from 'apache-arrow';

// 创建 Arrow 表
const table = Table.new(
    [
        FloatVector.from([1, 2, 3, 4, 5]),
        FloatVector.from([10, 20, 30, 40, 50])
    ],
    ['x', 'y']
);

// 高效查询
const sum = table.getColumn('y').toArray().reduce((a, b) => a + b, 0);

Arrow 的列式存储格式让数据在 Python、JavaScript、R、Julia 之间零拷贝传输成为可能。这对于前后端数据交互是一个革命性的改进。

4.3 前端的数据科学学习路径

但更深层的思考是:前端是否应该掌握数据科学的能力?

我的答案是肯定的。现代前端不再是简单的页面展示,而是数据驱动的交互应用。理解数据处理、理解机器学习的基本原理,将成为高级前端的必备技能。

Web 开发的殊途同归

5.1 Django vs Express:两种架构哲学

Django 是 Python Web 开发的旗舰框架,它的哲学是"约定优于配置"。ORM、表单处理、认证系统、管理后台——Django 提供了一站式解决方案。这种"全功能框架"的思路,让开发者可以快速搭建复杂的 Web 应用。

# Django 模型定义
from django.db import models
from django.contrib.auth.models import User

class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        ordering = ['-created_at']

# Django 视图
from django.shortcuts import render, get_object_or_404
from rest_framework import viewsets
from rest_framework.decorators import action

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    
    @action(detail=True, methods=['post'])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.publish()
        return Response({'status': 'published'})

Django 的优势在于:

  • 快速开发:内置的 admin 界面让 CRUD 操作无需额外代码
  • 安全性:内置 CSRF 保护、SQL 注入防护、XSS 过滤
  • 可扩展性:丰富的第三方应用生态

但 Django 也有其局限性:

  • 灵活性不足:Django 的"全功能"意味着你必须按照它的方式做事
  • 学习曲线陡峭:需要理解 ORM、视图、模板、中间件等多个概念
  • 性能开销:大而全的框架必然带来性能损耗

Express.js 则是 Node.js 世界的微框架代表:

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

const app = express();
app.use(express.json());

// 模型定义(使用 Mongoose)
const articleSchema = new mongoose.Schema({
    title: String,
    content: String,
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
    createdAt: { type: Date, default: Date.now }
});

const Article = mongoose.model('Article', articleSchema);

// 路由
app.get('/api/articles', async (req, res) => {
    const articles = await Article.find().sort({ createdAt: -1 });
    res.json(articles);
});

app.post('/api/articles', async (req, res) => {
    const article = new Article(req.body);
    await article.save();
    res.status(201).json(article);
});

app.listen(3000);

Express 的优势在于:

  • 灵活性:只提供基础功能,其他由你选择
  • 学习曲线平缓:理解中间件概念后即可上手
  • 性能:轻量级框架,开销小

但 Express 的灵活性也带来了问题:

  • 选择困难症:ORM 用 Sequelize、TypeORM 还是 Prisma?验证用 Joi、Yup 还是 class-validator?
  • 项目结构不一致:每个 Express 项目的结构都可能不同
  • 重复造轮子:很多功能需要自己实现或选择第三方库
维度 Django Express.js
架构风格 全功能框架(" batteries included ") 微框架
ORM 内置 Django ORM 需额外选择(Sequelize/TypeORM/Prisma)
认证授权 内置 需额外实现(Passport.js 等)
管理后台 内置 admin
学习曲线 陡峭 平缓
灵活性 较低
适用场景 大型项目、快速原型 中小型项目、API 服务
性能 中等

5.2 FastAPI:Python 的现代答案

如果说 Django 是 Python 的 Spring,那么 FastAPI 就是 Python 的 NestJS。FastAPI 采用声明式编程、依赖注入、类型注解,它的设计哲学与 TypeScript 生态高度契合。

from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session

# 数据库设置
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

# 数据库模型
class UserDB(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String, index=True)
    email = Column(String, unique=True, index=True)

# Pydantic 模型(用于 API 验证)
class UserBase(BaseModel):
    name: str
    email: str

class UserCreate(UserBase):
    pass

class User(UserBase):
    id: int
    
    class Config:
        orm_mode = True

# FastAPI 应用
app = FastAPI(title="User API", version="1.0.0")

# 依赖注入:数据库会话
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# 路由
@app.post("/users/", response_model=User)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    db_user = UserDB(**user.dict())
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

@app.get("/users/", response_model=List[User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    users = db.query(UserDB).offset(skip).limit(limit).all()
    return users

@app.get("/users/{user_id}", response_model=User)
def read_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(UserDB).filter(UserDB.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

这段代码的优雅让我惊叹:

  1. 类型安全:Pydantic 模型自动验证请求和响应
  2. 自动文档:访问 /docs 即可获得 Swagger UI 文档
  3. 异步支持:原生支持 async/await
  4. 依赖注入Depends 让代码解耦、可测试

对比 TypeScript 的 NestJS:

// NestJS 对比
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

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

@Controller('users')
export class UserController {
    constructor(
        @InjectRepository(User)
        private userRepository: Repository<User>
    ) {}
    
    @Post()
    async create(@Body() createUserDto: CreateUserDto) {
        const user = this.userRepository.create(createUserDto);
        return this.userRepository.save(user);
    }
    
    @Get()
    async findAll() {
        return this.userRepository.find();
    }
}

FastAPI 和 NestJS 的设计如此相似——装饰器路由、依赖注入、DTO 验证、ORM 集成。这或许预示着 Web 开发的未来:语言的边界正在模糊,好的设计理念会被跨语言借鉴。

5.3 性能对比:Node.js vs Python

让我们做一个简单的性能测试,对比 Node.js 和 Python 处理 HTTP 请求的能力:

Node.js (Express)

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

app.get('/api/data', (req, res) => {
    // 模拟数据库查询
    const data = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        value: Math.random()
    }));
    res.json(data);
});

app.listen(3000);

Python (FastAPI)

from fastapi import FastAPI
import random

app = FastAPI()

@app.get("/api/data")
async def get_data():
    data = [
        {"id": i, "value": random.random()}
        for i in range(1000)
    ]
    return data

# 使用 uvicorn 运行:uvicorn main:app --workers 4

使用 wrk 进行压力测试:

# Node.js
wrk -t12 -c400 -d30s http://localhost:3000/api/data
# Requests/sec:  15000

# Python (单 worker)
wrk -t12 -c400 -d30s http://localhost:8000/api/data
# Requests/sec:   8000

# Python (4 workers)
wrk -t12 -c400 -d30s http://localhost:8000/api/data
# Requests/sec:  25000

结果挺让人惊讶的:在单 worker 模式下,Node.js 的性能是 Python 的 2 倍。但当 Python 使用多 worker(利用多核 CPU)时,性能反超 Node.js。这说明:

  1. 单线程性能:Node.js 的 V8 引擎优于 Python 的解释器
  2. 多核利用:Python 的多进程模型可以充分利用多核 CPU
  3. 场景选择:I/O 密集型任务两者差距不大,CPU 密集型任务需要多进程

融会贯通

6.1 语言只是工具,思维才是核心

深入 Python 之后,我越来越确信一个观点:编程语言的差异,远不如编程思维的差异重要。无论是 JavaScript 还是 Python,优秀的代码都遵循相同的原则:

6.1.1 SOLID 原则

单一职责原则(Single Responsibility Principle)

# 不好的设计:一个类做太多事
class UserManager:
    def create_user(self, data): ...
    def send_email(self, user): ...
    def generate_report(self): ...

# 好的设计:职责分离
class UserService:
    def create_user(self, data): ...

class EmailService:
    def send_email(self, user): ...

class ReportService:
    def generate_report(self): ...

开闭原则(Open/Closed Principle)

from abc import ABC, abstractmethod

# 抽象基类
class PaymentProcessor(ABC):
    @abstractmethod
    def process(self, amount: float) -> bool:
        pass

# 具体实现
class AlipayProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        # 支付宝支付逻辑
        return True

class WechatProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        # 微信支付逻辑
        return True

# 使用
class PaymentService:
    def __init__(self, processor: PaymentProcessor):
        self.processor = processor
    
    def pay(self, amount: float) -> bool:
        return self.processor.process(amount)

# 新增支付方式无需修改现有代码
class StripeProcessor(PaymentProcessor):
    def process(self, amount: float) -> bool:
        return True

6.1.2 设计模式

设计模式是跨语言的。无论是 JavaScript 还是 Python,观察者模式、工厂模式、策略模式等都有相似的实现:

# Python 观察者模式
from typing import List, Callable

class EventEmitter:
    def __init__(self):
        self._listeners: dict[str, List[Callable]] = {}
    
    def on(self, event: str, callback: Callable):
        if event not in self._listeners:
            self._listeners[event] = []
        self._listeners[event].append(callback)
    
    def emit(self, event: str, *args, **kwargs):
        for callback in self._listeners.get(event, []):
            callback(*args, **kwargs)

# 使用
emitter = EventEmitter()
emitter.on('user_created', lambda user: print(f"User created: {user}"))
emitter.emit('user_created', {'name': 'Alice'})
// JavaScript 观察者模式(几乎相同)
class EventEmitter {
    constructor() {
        this.listeners = {};
    }
    
    on(event, callback) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }
        this.listeners[event].push(callback);
    }
    
    emit(event, ...args) {
        (this.listeners[event] || []).forEach(cb => cb(...args));
    }
}

6.1.3 编程思维的培养

掌握多门语言的价值,不在于"技多不压身",而在于从不同视角理解这些原则,形成更全面的技术判断力。

  • JavaScript 教会我:异步编程、函数式思维、事件驱动
  • Python 教会我:简洁优雅、实用主义、科学计算
  • TypeScript 教会我:类型安全、接口设计、静态分析

6.2 未来的融合趋势

技术发展的趋势是融合而非对立。我们看到 Python 和 JavaScript 都在做的事情:

6.2.1 WebAssembly 的崛起

WebAssembly(Wasm)让 Python 可以在浏览器中运行:

# 使用 Pyodide 在浏览器中运行 Python
import micropip
await micropip.install('numpy')

import numpy as np
arr = np.array([1, 2, 3, 4, 5])
result = arr * 2

这意味着前端可以在浏览器中使用 Python 的数据处理能力,而无需服务器参与。

6.2.2 PyScript 的革命

PyScript 让 Python 可以直接嵌入 HTML:

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />
    <script defer src="https://pyscript.net/latest/pyscript.js"></script>
</head>
<body>
    <div id="output"></div>
    
    <py-script>
        from js import document
        import numpy as np
        
        arr = np.array([1, 2, 3, 4, 5])
        result = arr * 2
        
        output = document.getElementById('output')
        output.innerHTML = f"Result: {result.tolist()}"
    </py-script>
</body>
</html>

6.2.3 跨语言借鉴

  • Node.js 的 worker_threads 借鉴了 Python 的多进程模型
  • TypeScript 的类型系统影响了 Python 的类型注解设计
  • Rust 的所有权系统正在影响 JavaScript 和 Python 的内存管理思路

6.2.4 全栈工程师的新定义

未来的工程师,可能不再被"前端"或"后端"的标签所限制。他们会根据场景选择最合适的工具:

  • Python 处理数据、训练模型、编写自动化脚本
  • JavaScript/TypeScript 构建用户界面、实现交互逻辑
  • Rust 编写高性能模块、系统级工具
  • Go 构建微服务、高并发后端

这不是"全栈"的泛化,而是技术能力的深化。真正的技术专家,不是掌握最多语言的人,而是知道何时使用哪门语言的人。


结语

写完这篇文章,我想起了一个古老的比喻:

"如果你手里只有一把锤子,那么所有问题看起来都像钉子。"

JavaScript 是我手中的第一把锤子,它帮助我构建了无数精彩的 Web 应用。从简单的页面交互到复杂的单页应用,从 jQuery 到 React,从回调地狱到 async/await——JavaScript 陪伴我走过了前端技术的每一个阶段。

但 Python 让我看到了另一片天空:

  • 数据科学的深邃:NumPy、Pandas、Scikit-learn 让数据处理变得优雅而高效
  • 自动化的便捷:几行 Python 脚本可以替代 hours of manual work
  • 科学计算的严谨:从物理模拟到金融建模,Python 是科学家的首选语言
  • Web 开发的简洁:FastAPI 的设计哲学让我重新审视"好的代码"的定义

这两门语言不是竞争对手,而是互补的伙伴。

技术的深度,来自于对一门语言的精通;技术的广度,来自于对多门语言的理解。而技术的智慧,来自于知道何时使用哪一门语言,加油,奥利给!

手撕 Claude Code-4: TodoWrite 与任务系统

作者 唐旺仔
2026年4月18日 16:05

第 4 章:TodoWrite 与任务系统

源码位置:src/tools/TodoWriteTool/src/tasks/src/utils/tasks.ts


4.1 两个容易混淆的概念

Claude Code 中有两个相关但不同的"任务"概念:

概念 说明 存储位置
Todo(任务清单) Claude 在当前会话中规划的工作步骤 AppState.todos
Task(系统任务) 运行时管理的实体(子代理、Shell 命令、后台会话等) AppState.tasks

本章先讲 TodoWrite(任务清单),再讲 Task System(系统任务)。


4.2 TodoWrite:Claude 的工作计划本

4.2.1 数据模型

// src/utils/todo/types.ts
type TodoItem = {
  content: string                                    // 任务描述
  status: 'pending' | 'in_progress' | 'completed'
  activeForm: string                                 // 任务的主动形式(如 "Reading src/foo.ts")
}

type TodoList = TodoItem[]

4.2.2 工具实现

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:65

async call({ todos }, context) {
  const appState = context.getAppState()
  
  // 每个代理有自己的 todo 列表(用 agentId 或 sessionId 区分)
  const todoKey = context.agentId ?? getSessionId()
  const oldTodos = appState.todos[todoKey] ?? []
  
  // 关键逻辑:如果所有任务都已完成,AppState 中清空为 []
  const allDone = todos.every(_ => _.status === 'completed')
  const newTodos = allDone ? [] : todos

  // 验证提醒:仅当 allDone && 3+ 项 && 无验证步骤时才触发
  let verificationNudgeNeeded = false
  if (
    feature('VERIFICATION_AGENT') &&
    getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
    !context.agentId &&   // 只在主线程触发,不在子代理
    allDone &&            // 必须是关闭列表的那一刻
    todos.length >= 3 &&
    !todos.some(t => /verif/i.test(t.content))
  ) {
    verificationNudgeNeeded = true
  }

  // 注意:API 是 setAppState(接收 prev → newState 函数),不是 updateAppState
  context.setAppState(prev => ({
    ...prev,
    todos: { ...prev.todos, [todoKey]: newTodos },
  }))

  // 返回值中 newTodos 是原始输入 todos(非清空后的 [])
  return {
    data: { oldTodos, newTodos: todos, verificationNudgeNeeded },
  }
}

4.2.3 关键设计点

1. 所有完成即清空

当所有 todo 都标记为 completed,AppState 中的列表被清空为 [] 而不是保留所有已完成项。这避免了 token 浪费(每次 API 调用都要序列化已完成的任务)。注意:工具返回值中的 newTodos 字段仍为原始 todos 输入,清空仅影响 AppState 存储层。

2. agentId 隔离

每个子代理有独立的 todo 列表(通过 agentId 作为 key)。主线程用 sessionId。这样并行运行的子代理不会互相干扰。

3. shouldDefer: true

TodoWrite 被标记为延迟披露,不在每次 API 请求中直接包含其 Schema。Claude 通过 ToolSearch 找到它。

4. 与 TodoV2(Task API)互斥

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:53 中:

isEnabled() {
  return !isTodoV2Enabled()
}

源码位置:src/utils/tasks.ts:133 isTodoV2Enabled() 在以下情况返回 true(即 TodoWrite 被禁用):

  • 环境变量 CLAUDE_CODE_ENABLE_TASKS=1
  • 非交互式会话(SDK 模式)

这意味着 SDK 用户默认使用 Task API 而非 TodoWrite。

5. verificationNudgeNeeded

这是一个"行为引导"机制。触发条件(需同时满足):

  • Feature flag VERIFICATION_AGENT 已开启(编译时 gate)
  • GrowthBook flag tengu_hive_evidence 为 true
  • 当前在主线程(!context.agentId
  • 当前调用正在关闭整个列表(allDone === true
  • 任务数 ≥ 3
  • 没有任何任务内容匹配 /verif/i

满足时,工具结果会追加提示,要求 Claude 在写最终摘要前先 spawn 验证代理。


4.3 Task System:运行时实体管理

Task System 管理着 Claude Code 中所有的"运行中的实体"。

4.3.0 TaskStateBase:所有任务的公共基础

源码位置:src/Task.ts:45

type TaskStateBase = {
  id: string           // 任务 ID(带类型前缀,见下表)
  type: TaskType       // 'local_bash' | 'local_agent' | 'remote_agent' | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'
  status: TaskStatus   // 'pending' | 'running' | 'completed' | 'failed' | 'killed'
  description: string
  toolUseId?: string   // 触发此任务的 tool_use block ID(用于通知回调)
  startTime: number
  endTime?: number
  totalPausedMs?: number
  outputFile: string   // 磁盘输出文件路径(快照,/clear 后不变)
  outputOffset: number // 已读取字节偏移量(用于增量 delta 读取)
  notified: boolean    // 是否已发送完成通知(防止重复通知)
}

Task ID 前缀表(src/Task.ts:79):

类型 前缀 示例
local_bash b b3f9a2c1
local_agent a a7d8e4f2
remote_agent r r2a1b3c4
in_process_teammate t t5e6f7a8
local_workflow w w9b0c1d2
monitor_mcp m m3e4f5a6
dream d d7b8c9d0
主会话(特殊) s s1a2b3c4

local_agent 和主会话后台化共用 a 前缀(LocalAgentTaskState),但主会话后台化通过 LocalMainSessionTask.ts 中独立的 generateMainSessionTaskId() 使用 s 前缀,与普通子代理区分。

4.3.1 TaskState 联合类型

源码位置:src/tasks/types.ts

// 注意:类型名是 TaskState,不是 Task
type TaskState =
  | LocalShellTaskState        // 长运行 Shell 命令
  | LocalAgentTaskState        // 本地子代理(含后台化主会话)
  | RemoteAgentTaskState       // 远程代理
  | InProcessTeammateTaskState // in-process 队友(团队协作)
  | LocalWorkflowTaskState     // 工作流
  | MonitorMcpTaskState        // MCP 监控
  | DreamTaskState             // 记忆整合子代理(auto-dream)

注意LocalMainSessionTask 不是独立的联合类型成员。它是 LocalAgentTaskState 的子类型:

// src/tasks/LocalMainSessionTask.ts
type LocalMainSessionTaskState = LocalAgentTaskState & { agentType: 'main-session' }

这意味着后台化主会话与普通子代理共用同一个 type: 'local_agent' 标识符,通过 agentType 字段区分。

4.3.2 主会话后台化(LocalMainSessionTask)

这是一个特殊功能:用户按 Ctrl+B 两次可以将当前对话"后台化",然后开始新的对话。后台化后,原查询继续在后台独立运行。

源码位置:src/tasks/LocalMainSessionTask.ts

// LocalMainSessionTaskState 不是独立类型,而是 LocalAgentTaskState 的子类型
type LocalMainSessionTaskState = LocalAgentTaskState & {
  agentType: 'main-session'  // 唯一区分标识
}
// type: 'local_agent'(继承)
// taskId: 's' 前缀(区分普通代理的 'a' 前缀)
// messages?: Message[](继承,存储后台查询的消息历史)
// isBackgrounded: boolean(继承,true = 后台,false = 前台展示中)
// 无独立 transcript 字段

关键函数:

// 注册后台会话任务,返回 { taskId, abortSignal }
registerMainSessionTask(description, setAppState, agentDefinition?, abortController?)

// 启动真正的后台查询(wrap runWithAgentContext + query())
startBackgroundSession({ messages, queryParams, description, setAppState })

// 将后台任务切回前台展示
foregroundMainSessionTask(taskId, setAppState): Message[]

为什么需要隔离的转录路径? 后台任务使用 getAgentTranscriptPath(agentId) 而不是主会话的路径。若用同一路径,/clear 会意外覆盖后台会话数据。后台任务通过 initTaskOutputAsSymlink() 创建软链接,/clear 重链接时不影响后台任务的历史记录。

4.3.3 本地代理任务(LocalAgentTask)

每个子代理对应一个 LocalAgentTaskState

源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:116

type LocalAgentTaskState = TaskStateBase & {
  type: 'local_agent'
  agentId: string
  prompt: string
  selectedAgent?: AgentDefinition
  agentType: string          // 区分子类型,'main-session' = 后台化主会话
  model?: string
  abortController?: AbortController
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress   // 进度信息(包含 toolUseCount、tokenCount)
  retrieved: boolean         // 结果是否已被取回
  messages?: Message[]       // 代理的消息历史(UI 展示用)
  lastReportedToolCount: number
  lastReportedTokenCount: number
  isBackgrounded: boolean    // false = 前台展示中,true = 后台运行
  pendingMessages: string[]  // SendMessage 排队的消息,在工具轮边界处理
  retain: boolean            // UI 持有此任务(阻止驱逐)
  diskLoaded: boolean        // 是否已从磁盘加载 sidechain JSONL
  evictAfter?: number        // 驱逐时间戳(任务完成后设置)
}

注意:文档中常见误写 toolUseCount 为 LocalAgentTaskState 的直接字段,实际它在 progress.toolUseCount 中。status 字段继承自 TaskStateBase'running' | 'completed' | 'failed' | 'stopped')。

进度追踪通过专门的辅助函数:

// src/tasks/LocalAgentTask/LocalAgentTask.tsx
createProgressTracker()           // 初始化 ProgressTracker(分别追踪 input/output tokens)
updateProgressFromMessage()       // 从 assistant message 累积 token 和工具调用数
getProgressUpdate()               // 生成 AgentProgress 快照(供 UI 消费)
createActivityDescriptionResolver() // 通过 tool.getActivityDescription() 生成人类可读描述

Token 追踪的精妙设计ProgressTracker 分开存储 latestInputTokens(Claude API 累积值,取最新)和 cumulativeOutputTokens(逐轮累加),避免重复计数。

4.3.4 in-process 队友任务(InProcessTeammateTask)

这是 Agent Teams 的核心数据结构:

src/tasks/InProcessTeammateTask/types.ts

type TeammateIdentity = {
  agentId: string        // e.g., "researcher@my-team"
  agentName: string      // e.g., "researcher"
  teamName: string
  color?: string
  planModeRequired: boolean
  parentSessionId: string  // Leader 的 sessionId
}

type InProcessTeammateTaskState = TaskStateBase & {
  type: 'in_process_teammate'
  identity: TeammateIdentity        // 队友身份(存储在 AppState 中的 plain data)
  prompt: string
  model?: string
  selectedAgent?: AgentDefinition
  abortController?: AbortController         // 终止整个队友
  currentWorkAbortController?: AbortController  // 终止当前轮次
  awaitingPlanApproval: boolean             // 是否等待 plan 审批
  permissionMode: PermissionMode            // 独立权限模式(Shift+Tab 切换)
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress
  messages?: Message[]              // UI 展示用(上限 50 条,TEAMMATE_MESSAGES_UI_CAP)
  pendingUserMessages: string[]     // 查看该队友时用户输入的队列消息
  isIdle: boolean                   // 是否处于空闲(等待 leader 指令)
  shutdownRequested: boolean        // 是否已请求关闭
  lastReportedToolCount: number
  lastReportedTokenCount: number
}

常见误解mailbox 字段不存在于 InProcessTeammateTaskState。队友间的通信邮箱存储在运行时上下文 teamContext.inProcessMailboxes(AsyncLocalStorage 中),不在 AppState 里。pendingUserMessages 是用户从 UI 发给该队友的消息队列,与邮箱是两回事。

内存上限设计messages 字段上限 50 条(TEAMMATE_MESSAGES_UI_CAP),超出后从头部截断。原因是生产环境出现过单个 whale session 启动 292 个 agent、内存达 36.8GB 的情况,根本原因正是此字段持有第二份完整消息副本。

4.3.5 记忆整合任务(DreamTask)

源码位置:src/tasks/DreamTask/DreamTask.ts

type DreamTaskState = TaskStateBase & {
  type: 'dream'
  phase: 'starting' | 'updating'    // starting → updating(首个 Edit/Write 后翻转)
  sessionsReviewing: number          // 正在整理的会话数量
  filesTouched: string[]             // 被 Edit/Write 触碰的文件(不完整,仅 pattern-match 到的)
  turns: DreamTurn[]                 // assistant 轮次(工具调用折叠为计数)
  abortController?: AbortController
  priorMtime: number                 // 用于 kill 时回滚 consolidationLock 时间戳
}

DreamTask 是"auto-dream"记忆整合的 UI 表面层。它不改变子代理运行逻辑,只是让原本不可见的 fork agent 在 footer pill 和 Shift+Down 对话框中可见。子代理按 4 阶段 prompt 运行(orient → gather → consolidate → prune),但 DreamTask 不解析阶段,只通过工具调用类型推断 phase。


4.4 任务注册与生命周期框架

源码位置:src/utils/task/framework.ts

4.4.1 registerTask:注册与恢复

registerTask(task: TaskState, setAppState): void

注册一个新任务时,有两条路径:

新建existing === undefined):

  1. 将 task 写入 AppState.tasks[task.id]
  2. 向 SDK 事件队列发出 task_started 事件

恢复/替换existing !== undefined,如 resumeAgentBackground):

  1. 合并保留以下 UI 状态(避免用户正在查看的面板闪烁):
    • retain:UI 持有标记
    • startTime:面板排序稳定性
    • messages:用户刚发送的消息还未落盘
    • diskLoaded:避免重复加载 sidechain JSONL
    • pendingMessages:待处理消息队列
  2. 发出 task_started(防止 SDK 重复计数)

4.4.2 任务完成通知(XML 格式)

子代理/后台任务完成时,通过 enqueuePendingNotification 将 XML 推入消息队列:

<task_notification>
  <task_id>a7d8e4f2</task_id>
  <tool_use_id>toolu_01xxx</tool_use_id>  <!-- 可选 -->
  <output_file>/tmp/.../tasks/a7d8e4f2.output</output_file>
  <status>completed</status>
  <summary>Task "修复登录 bug" completed successfully</summary>
</task_notification>

这段 XML 在下一轮 API 调用前作为 user 消息被注入 messages,使 LLM 感知到后台任务完成。

4.4.3 evictTerminalTask:两级驱逐

任务完成后并不立即从 AppState.tasks 中删除,而是两级驱逐:

任务完成 → status='completed' + notified=true
  │
  ├─ 如果 retain=true 或 evictAfter > Date.now()
  │    → 保留(UI 正在展示,等待 30s grace period 后驱逐)
  │
  └─ 否则 → 立即从 AppState.tasks 中删除(eagerly evict)
               作为保底,generateTaskAttachments() 也会在下次 poll 时驱逐

PANEL_GRACE_MS = 30_000(30秒)是 coordinator panel 中 agent 任务的展示宽限期,确保用户能看到结果后再消失。


4.5 后台 API 任务工具

用户(通过 Claude)可以创建和管理后台任务:

// TaskCreateTool / TaskUpdateTool / TaskStopTool / TaskGetTool / TaskListTool

这些工具允许 Claude 自己创建和监控后台任务,实现真正的异步多任务处理。注意:这套 Task API 工具仅在 TodoV2 模式下启用(即 isTodoV2Enabled() === true),与 TodoWrite 互斥。


4.6 任务输出的磁盘管理

源码位置:src/utils/task/diskOutput.ts

4.6.1 输出文件路径

// 注意:路径不是 .claude/tasks/,而是项目临时目录下的会话子目录
getTaskOutputPath(taskId) → `{projectTempDir}/{sessionId}/tasks/{taskId}.output`

为什么包含 sessionId? 防止同一项目的并发 Claude Code 会话互相踩踏输出文件。路径在首次调用时被 memoize(let _taskOutputDir),/clear 触发 regenerateSessionId() 时不会重新计算,确保跨 /clear 存活的后台任务仍能找到自己的文件。

4.6.2 DiskTaskOutput 写队列

任务输出通过 DiskTaskOutput 类异步写入磁盘:

class DiskTaskOutput {
  append(content: string): void  // 入队,自动触发 drain
  flush(): Promise<void>         // 等待队列清空
  cancel(): void                 // 丢弃队列(任务被 kill 时)
}

核心设计要点

  1. 写队列#queue: string[] 平铺数组,单个 drain 循环消费,chunk 写入后立即可被 GC,避免 .then() 链持有引用导致内存膨胀

  2. 5GB 上限:超限后追加截断标记并停止写入:

    [output truncated: exceeded 5GB disk cap]
    
  3. O_NOFOLLOW 安全:Unix 上用 O_NOFOLLOW flag 打开文件,防止沙箱中的攻击者通过创建软链接将 Claude Code 写入任意宿主文件

  4. 事务追踪:所有 fire-and-forget 异步操作(initTaskOutputevictTaskOutput 等)注册到 _pendingOps: Set<Promise>,测试可通过 allSettled 等待全部完成,防止跨测试的 ENOENT 竞争

4.6.3 增量输出读取(OutputOffset)

TaskStateBase.outputOffset 记录已消费的字节偏移,实现增量读取:

// 仅读取 fromOffset 之后的新内容(最多 8MB)
getTaskOutputDelta(taskId, fromOffset): Promise<{ content: string; newOffset: number }>

framework.ts 中的 generateTaskAttachments() 在每次 poll 时调用 getTaskOutputDelta,将新增内容附加到 task_status attachment 中推送给 LLM,避免重复加载完整输出文件。

4.6.4 关键函数对比

函数 是否删磁盘文件 是否清内存 适用场景
evictTaskOutput(taskId) 是(flush 后清 Map) 任务完成,结果已消费
cleanupTaskOutput(taskId) 彻底清理(测试、取消)
flushTaskOutput(taskId) 读取前确保写入完成

4.7 Cron 定时任务

通过 Cron 工具,可以创建定期自动运行的任务:

// CronCreate / CronDelete / CronList 工具
// 底层通过 src/utils/cron.ts 管理

// 使用示例(Claude 会这样调用):
// CronCreate({ schedule: '0 9 * * 1', command: '/review-pr' })
// → 每周一早上 9 点自动运行 /review-pr

4.8 流程图:任务的完整生命周期

用户输入复杂任务
      │
      ▼
Claude 调用 TodoWrite         ← 规划工作步骤
  todos: [
    { content: '读取代码', status: 'pending' },
    { content: '修改功能', status: 'pending' },
    { content: '运行测试', status: 'pending' },
  ]
      │
      ▼
Claude 开始执行第一步
TodoWrite: { status: 'in_progress' }  ← 标记进行中
      │
      ├─ 如果需要并行工作:
      │    Agent({ subagent_type: 'general-purpose', ... })
      │         │
      │         ▼
      │    创建 LocalAgentTaskState(id: 'a-xxxxxxxx',agentId 同值)
      │    子代理在独立上下文中运行
      │         │
      │         ▼
      │    子代理有自己的 Todo 列表(隔离)
      │
      ▼
每步完成后:
TodoWrite: { status: 'completed' }    ← 标记完成
      │
      ▼
所有步骤完成:
TodoWrite: todos.every(done) → 清空列表 []
      │
      ▼
Agent Loop 检测到无工具调用 → 停止

小结

组件 职责 源码位置
TodoWriteTool 会话内工作计划追踪(交互模式) src/tools/TodoWriteTool/TodoWriteTool.ts
Task API 工具 后台任务创建与管理(SDK/非交互模式) TaskCreateTool / TaskStopTool
TaskStateBase 所有任务的公共字段(含 id、status、outputOffset) src/Task.ts
LocalMainSessionTask 主会话后台化(LocalAgentTaskState 子类型) src/tasks/LocalMainSessionTask.ts
LocalAgentTaskState 子代理生命周期管理 src/tasks/LocalAgentTask/LocalAgentTask.tsx
InProcessTeammateTaskState 团队队友状态(含内存上限 50 条) src/tasks/InProcessTeammateTask/types.ts
DreamTaskState 记忆整合子代理的 UI 表面层 src/tasks/DreamTask/DreamTask.ts
任务框架 registerTask / evictTerminalTask / 通知 XML src/utils/task/framework.ts
磁盘输出管理 DiskTaskOutput 写队列 / 增量读取 / 5GB 上限 src/utils/task/diskOutput.ts
Cron 定时任务 自动化周期任务 CronCreate/Delete/List 工具

关键设计约定总结

约定 说明
LocalMainSessionTask 不是独立类型 它是 LocalAgentTaskState & { agentType: 'main-session' }
TaskState(不是 Task) 联合类型的正确名称
setAppState(不是 updateAppState AppState 更新的实际 API
toolUseCountprogress 不是 LocalAgentTaskState 的直接字段
TodoWrite 与 Task API 互斥 通过 isTodoV2Enabled()isEnabled() 中切换

零成本打造专业域名邮箱:Cloudflare + Gmail 终极配置保姆级全攻略

作者 Immerse
2026年4月18日 11:00

大家好,我是 Immerse

专注分享 AI 玩法独立开发AI 出海的 AGI 实践者,更多干货欢迎关注公众号 #沉浸式AI 或访问 yaolifeng.com


如果你手上有自己的域名,只是想把 hi@你的域名.com 用起来收发邮件,又不想每年给 Google Workspace 或 Microsoft 365 交钱,这套 Cloudflare Email Routing + Gmail 现在依然够用。

Cloudflare Email Routing 负责把别人发到你域名邮箱的邮件转进 Gmail。Gmail 负责从这个地址回信。

它不是完整企业邮箱,但拿来放在博客、作品集、简历、联系页、独立开发者官网上,已经很够用了。

主要边界

  • 能做到:别人发到你的域名邮箱,你在 Gmail 里收到;你也能在 Gmail 里用这个域名地址发信和回信
  • 做不到:Cloudflare 不给你真正的邮箱空间,也不提供发信服务器;多人协作、共享日历、管理员后台,这套都没有
  • 要注意:Cloudflare 开启 Email Routing 时会接管你这个域名的邮件 MX 记录。如果你这个域名已经在用腾讯企业邮、阿里企业邮、Google Workspace,不要直接点开通

如果你要一个看起来专业、能正常往来邮件的联系邮箱,这套很合适。你要的是团队邮箱系统,那就别省这点钱,直接上正式服务。

准备资料

  1. 一个已经接入 Cloudflare DNS 的域名
  2. 一个你能正常登录的 Gmail 账号
  3. 能开启 Google 两步验证
  4. 一个备用邮箱用来做测试,QQOutlook、另一个 Gmail 都行

部分参考文档:

先把收件打通

先做最小可用版:让别人发到 hi@你的域名.com 的邮件,能够进你的 Gmail。

  1. 登录 Cloudflare,进入你的域名,打开 Email -> Email Routing
  2. 第一次开通时,Cloudflare 会让你添加或替换邮件相关的 MXTXT 记录。这里要认真看一眼,如果面板提示要删除旧的 MX,就代表旧邮箱服务会一起停掉
  3. 开通后,去 Routing rules 里创建一个自定义地址。比如把 hi@你的域名.com 转发到 yourname@gmail.com
  4. Cloudflare 会往你的 Gmail 发一封验证邮件。点掉验证之前,这条转发规则不算真的生效
  5. 验证完成后,用另一个邮箱发一封测试邮件到你的域名邮箱,不要直接用同一个 Gmail 自己给自己发,很多时候会把你绕晕

你看到的正确结果应该是这样的:

  • Gmail 收到了这封信
  • 收件人还是你的域名邮箱,不是裸露的 Gmail
  • Cloudflare 面板里的规则状态是已启用

小建议:有建明确地址,不要一上来开 Catch-allCatch-all 很方便,但也很容易把拼错地址、垃圾邮件、爬虫乱发的邮件一起吞进来。大多数人先用 hicontacthello 这种固定地址就够了。

再把发件打通

收件只是前半段。那怎么用对应的这个域名发出去。

注意,Cloudflare 不负责发信。它只是把邮件转进来。真正往外发信的,还是 Gmail,或者你后面换掉的别家 SMTP 服务。

开 Google 两步验证,生成应用专用密码

Gmail 不会让你直接拿账号密码去配 SMTP。你要用的是应用专用密码。

  1. 打开 Google 账号的安全页,先把两步验证开起来
  2. 开完以后,再进入 应用专用密码
  3. 新建一个密码,名字随便写,比如“域名邮箱”
  4. Google 会给你一串 16 位密码,保存下来

如果找不到“应用密码”入口,可能的两个原因:

  • 你的账号还没开两步验证
  • 你开了 Advanced Protection,Google 官方说明里明确写了,这种模式下应用专用密码不可用

碰到第二种情况,就别死磕了。要么退出 Advanced Protection,要么直接跳到后面的“换第三方 SMTP”方案。

在 Gmail 里添加对应的发件域名

  1. 打开 Gmail,点右上角设置,进入“查看所有设置”
  2. 打开 账号和导入
  3. 在“用这个地址发送邮件”里点“添加其他电子邮件地址”
  4. 名字填你想展示给别人的名字,邮箱填你的域名地址,比如 hi@你的域名.com
  5. Treat as an alias 这个选项,默认保留勾选就行。你本来就是在给同一个人加另一个发件地址
  6. SMTP 配成下面这样
SMTP 服务器:smtp.gmail.com
端口:465
用户名:你的 Gmail 完整地址
密码:刚刚生成的 16 位应用专用密码
加密方式:SSL

如果 465 + SSL 连不上,再试一次 587 + TLS。这是 Google 官方文档里也支持的组合。

  1. 提交后,Gmail 会往你的域名邮箱发一封确认邮件
  2. 这封确认邮件会先到 Cloudflare,再转发进你的 Gmail
  3. 点掉确认链接,或者把验证码填回去,这个发件地址就能用了

配置自动选择回复邮件

注意,如果你想要别人给你发了邮件,你想用对应的邮件回复,这一步设置是必须的。

设置下面这一项

测试整个流程

用其他邮箱,比如 QQ,163 邮箱测试一遍

  1. 用另一个邮箱发信到 hi@你的域名.com
  2. 去 Gmail 收件箱里打开这封信,直接点回复
  3. 发出去之前,看一眼 From,确认显示的是你的域名邮箱
  4. 回到对方邮箱,确认能收到回复

到这一步,主流程就已经跑通了。

对方为什么还会看到 via gmail.com 或者 on behalf of

这不是你哪里配错了,很多时候是 Gmail 这条发信链路本来就有这个边界。

你现在这套方案里:

  • Cloudflare 负责收件转发
  • Gmail 负责真正把邮件发出去

所以在部分收件客户端里,对方可能会看到你的 Gmail 痕迹,比如 via gmail.com,或者 yourname@gmail.com on behalf of hi@你的域名.com

这在个人沟通、博客联系邮箱、独立开发者业务往来里通常问题不大

最容易踩的几个坑

  • Cloudflare 规则建好了但一直收不到邮件。先看目标 Gmail 有没有点验证邮件,没验证就不会真正转发
  • 一开通 Email Routing,原来的企业邮箱突然废了。因为 MX 已经被 Cloudflare 接管了
  • Gmail 一直提示用户名或密码错误。这里填的不是你的 Google 登录密码,是 16 位应用专用密码
  • Google 账号里根本没有应用专用密码。先查两步验证,再查是不是开了 Advanced Protection
  • 你给自己发测试邮件,觉得没收到。先换一个别的邮箱测,自己给自己发最容易被 Gmail 的会话和去重逻辑干扰判断
  • 对方看到 via gmail.com。这通常不是配置错,是 Gmail SMTP 的边界

真要说这套配置最难的地方,也就两个:一个是别把 MX 记录改糊涂,另一个是 Gmail 一定要用应用专用密码。

iOS逆向工程:详细解析ptrace反调试机制的破解方法与实战步骤

2026年4月15日 11:44

Ptrace 提供了一种父进程可以控制子进程运行的机制,并可以检查和改变它的核心image。

  • 它主要用于实现断点调试。

1、一个被跟踪的进程运行中,直到发生一个信号,则进程被中止,并且通知其父进程。 2、在进程中止的状态下,进程的内存空间可以被读写。父进程还可以使子进程继续执行,并选择是否是否忽略引起中止的信号。

  • 本文采用tweak 的方式进行MSHookFunction

在iOS应用安全中,除了反调试机制,代码混淆也是一种重要的保护手段。IpaGuard是一款强大的iOS IPA文件混淆工具,无需源码即可对代码和资源进行混淆加密,支持多种开发平台,有效增加反编译难度。它提供代码混淆、资源文件混淆、调试信息清理等功能,可以帮助开发者保护应用免受逆向工程攻击。

软件环境:Xcode 硬件环境:iPhone5越狱手机、Mac 开发工具:Cycript、LLDB、logos Tweak、hopper、MonkeyDev、AFLEXLoader、dumpdecrypted、debugserver、ssh、class_dump、hook

  • 破解方案

1、运行时期,断点ptrace,直接返回 2、分析如何调用的ptrace,hook ptrace 3、通过tweak,替换disable_gdb函数 4、修改 PT_DENY_ATTACH

I、运行时期,断点ptrace,直接返回

初始化应用程序,而不是运行中附着

iPhone:~ root#  debugserver -x posix *:12345  /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet

初始化程序,目的是从程序入口就开始进行附着,这样我们就可以在一些安全防护代码执行之前,进行破解。

跳过ptrace:过命令thread return直接返回,以跳过函数的逻辑。

 (lldb) br set -n ptrace
 Breakpoint 2: where = libsystem_kernel.dylib`__ptrace, address = 0x00000001966af2d4
 (lldb) br command add 2
 Enter your debugger command(s).  Type 'DONE' to end.
 > thread return
 > c
 > DONE

II、分析如何调用的ptrace,hook ptrace

去掉ptrace的思路

  • 当程序运行后,使用 debugserver *:1234 -a BinaryName 附加进程出现 segmentfault 11 时,一般说明程序内部调用了ptrace 。
  • 为验证是否调用了ptrace 可以 debugserver -x backboard *:1234 /BinaryPath(这里是完整路径),然后下符号断点 b ptrace,c 之后看ptrace第一行代码的位置,然后 p $lr 找到函数返回地址,再根据 image list -o -f 的ASLR偏移,计算出原始地址。最后在 IDA 中找到调用ptrace的代码,分析如何调用的ptrace。
  • 开始hook ptrace。

2.0 准备工作:砸壳

  • 砸壳

blog.csdn.net/z929118967/…

  • 查找app路径
iPhone:~ root# ps -e |grep AlipayWallet
  714 ??         0:26.44 /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
  736 ttys000    0:00.01 grep AlipayWallet

  • 获取NSDocumentDirectory
iPhone:~ root# cycript -p AlipayWallet
cy# [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]
#"file:///var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/"

  • scp 拷贝文件
devzkndeMacBook-Pro:decrypted devzkn$ scp /Users/devzkn/Downloads/kevin-software/ios-Reverse_Engineering/dumpdecrypted-master/dumpdecrypted.dylib iphone150:/var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/

devzkndeMacBook-Pro:decrypted devzkn$ scp iphone150:/var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/AlipayWallet.decrypted /Users/devzkn/decrypted/AlipayWallet

  • class-dump
devzkndeMacBook-Pro:bin devzkn$ class-dump --arch armv7 /Users/devzkn/decrypted/AlipayWallet10.1.8/AlipayWallet.decrypted -H -o /Users/devzkn/decrypted/AlipayWallet10.1.8/head

-查看 bundleIdentifier

iPhone:~ root# cycript -p AlipayWallet
cy# [[NSBundle mainBundle] bundleIdentifier]
@"com.alipay.iphoneclient"

2.1 编写 tweak 分析

%hook DFClientDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    %log();

    // 打印某个类的所有方法的,查看所有方法的执行顺序

     [KNHook hookClass:@"H5WebViewController"];//aluLoginViewController
     [KNHook hookClass:@"TBSDKServer"];//getUaPageName    aluMTopService _tokenLoginInvoker

     [KNHook hookClass:@"TBSDKMTOPServer"];//getUaPageName    aluMTopService _tokenLoginInvoker

    return %orig;
}
%end

2.2 具体步骤

2.2.1 debugserver

iPhone:~ root#  debugserver *:12345 -a AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Attaching to process AlipayWallet...
Segmentation fault: 11

当程序运行后,使用 debugserver *:1234 -a BinaryName 附加进程出现 segmentfault 11 时,一般说明程序内部调用了ptrace 。

iPhone:~ root#  debugserver *:12345 -x backboard /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Segmentation fault: 11

2.2.2 分析如何调用的ptrace

  • debugserver -x
iPhone:~ root# debugserver -x *:12345 /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
error: invalid TYPE for the --launch=TYPE (-x TYPE) option: '*:12345'
Valid values TYPE are:
  auto       Auto-detect the best launch method to use.
  posix      Launch the executable using posix_spawn.
  fork       Launch the executable using fork and exec.
  backboard  Launch the executable through BackBoard Services.

总共有四种类型

debugserver -x backboard *:1234 /var/mobile/...... 把这个backboard改成posix试试

iPhone:~ root#  debugserver -x posix *:12345  /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Listening to port 12345 for a connection from *...

  • 在ptrace上下断点,找到调用ptrace的地方
(lldb)  b ptrace
Breakpoint 1: no locations (pending).
WARNING:  Unable to resolve breakpoint to any actual locations.

(lldb) c
Process 1657 resuming

  • 关闭Target,重新启动Target
1 location added to breakpoint 1
Process 1657 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x37a13e64 libsystem_kernel.dylib`__ptrace
libsystem_kernel.dylib`__ptrace:
->  0x37a13e64 <+0>:  ldr    r12, [pc, #0x4]           ; <+12>
    0x37a13e68 <+4>:  ldr    r12, [pc, r12]
    0x37a13e6c <+8>:  b      0x37a13e74                ; <+16>
    0x37a13e70 <+12>: rsbeq  r9, r11, #192, #2
Target 0: (AlipayWallet) stopped.
(lldb)  p/x $lr
(unsigned int) $0 = 0x0000bfbb

由此可见ptrace函数在libsystem_kernel.dylib这个动态库中,使用时才进行加载,不是静态放在本地的,所以我们不能简单地去tweak ptrace函数。

(lldb) image list -o -f |grep AlipayWallet
[  0] 0x00000000 /private/var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet(0x0000000000004000)

所以ptrace的调用者位于0x0000bfbb - 0x00000000 = 0x0000bfbb处,如图所示:

  • 在hopper使用go to add ,快捷键G

Flutter应用代码混淆完整指南:Android与iOS平台配置详解

2026年4月14日 14:37

Flutter中的代码混淆

代码混淆可以隐藏你的Dart代码中的函数和类名,让 反编译 App变得困难。对于更全面的混淆需求,特别是针对iOS IPA文件,可以使用专业工具如IpaGuard,它支持无需源码的代码和资源混淆,兼容Flutter等多种开发平台,有效增加反编译难度。

注:Dart的混淆还没有经过完全的测试,如果发现问题请到GitHub上提 issue 。关于混淆的问题,还可以参考 Stack Overflow 上的这个问题。

Flutter中的混淆配置其实是在Android和iOS端分别配置的。

Android

<ProjectRoot>/android/gradle.properties 文件中添加如下代码:

extra-gen-snapshot-options=--obfuscate

默认情况下,Flutter不会混淆或者缩减Android host,如果你使用了第三方的Java或者Android库,那么你可能需要减小APK体积,或者防止你的App被反编译。

  • Step 1:配置Proguard文件

新建 /android/app/proguard-rules.pro 文件,然后添加如下配置:


#Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.**  { *; }
-keep class io.flutter.util.**  { *; }
-keep class io.flutter.view.**  { *; }
-keep class io.flutter.**  { *; }
-keep class io.flutter.plugins.**  { *; }

上面的配置只保护Flutter库,其他额外的库(比如Firebase)需要你自己添加配置。

  • Step 2:

打开 /android/app/build.gradle 文件,定位到 buildTypes 处,在 release 配置中将 minifiyEnableduseProguard 标志设为true,同时还需要指向Step1中创建的ProGuard文件:


android {
    ...
    buildTypes {
        release {
            signingConfig signingConfigs.debug
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

注意混淆和缩减无用代码会加长App的编译时间。

iOS

  • Step 1:修改 "build aot"

<ProjectRoot>/packages/flutter_tools/bin/xcode_backend.sh 文件中添加 build aot flag:

${extra_gen_snapshot_options_or_none}

然后定义这个flag:


local extra_gen_snapshot_options_or_none=""
if [[ -n "$EXTRA_GEN_SNAPSHOT_OPTIONS" ]]; then
  extra_gen_snapshot_options_or_none="--extra-gen-snapshot-options=$EXTRA_GEN_SNAPSHOT_OPTIONS"
fi
  • Step 2:应用你的修改

在你的App的根目录下运行以下两条命令:


git commit -am "Enable obfuscation on iOS"
flutter
  • Step 3:更改release配置

<ProjectRoot>/ios/Flutter/Release.xcconfig 中添加下面这行:

EXTRA_GEN_SNAPSHOT_OPTIONS=--obfuscate

对于iOS平台,如果需要更强大的混淆保护,可以考虑使用IpaGuard这样的工具,它可以直接对IPA文件进行混淆加密,支持代码和资源文件的全面混淆,无需源码即可操作,并兼容Flutter应用,提供即时测试功能。

❌
❌