【AI】VibeCoding最佳姿势

【AI】VibeCoding最佳姿势

本文旨在给跨领域或者不懂编程的人,推荐一种Vibe Coding的方案

作为一名每天和代码打交道的 Android 工程师,我见证了 AI 如何彻底改变编程。现在,一个最前沿的词汇正风靡开发者圈子——Vibe Coding(氛围感编程/情绪流编程)

简单来说,Vibe Coding 就是你只管提需求、出思路、把握方向(负责 Vibe),而把敲键盘、写具体代码的脏活累活全丢给 AI

这是否意味着不懂编程的普通人也能轻松写出复杂的软件?答案是:能,但有前提。 很多人用 AI 写代码,刚开始很顺,到后面功能一多,代码就变成了一团乱麻(俗称“屎山”),完全无法维护。

如何让不懂编程的你,也能用 AI 写出容易迭代和进化的高质量代码?以下是我为你整理的“工程师级”实战指南。

一、不要说完产品形态就启动编码

非编程人员最容易犯的错误,就是一开始就追求完美,恨不得把所有能想到的功能一次性堆砌出来。你可能会兴奋地 向 AI 描述一个完整的产品形态 ,比如要做一个带推荐算法、实时聊天、会员体系、后台管理面板的社交平台,连字体配色和动效细节都交代得清清楚楚。这种热情完全可以理解,但在 VibeCoding 的节奏里,现有的AI编码大概率不能很好的完成开发工作,Cursor和CC这种辅助编码工具的工程设计和其背后的LLM模型已经很强大,但是依然不能完全理解透彻你的需求,急于求成恰恰是最危险的起点。

当你把一整部产品说明书丢给 AI 时,它并不会像资深工程师那样帮你拆解任务,反而会 因为上下文过长、约束条件过多而迅速“失焦” ,这种在业内叫噪声太多。

最后要么是生成一堆看似完整实则到处报错的代码,要么是开始胡言乱语,把不同框架的语法混在一起,让你连调试都无从下手。更糟的是,作为非专业程序员,你很难分辨哪些是致命错误、哪些可以暂时忽略,最后只会被满屏的红字搞得心力交瘁,误以为编程太难、自己不适合。

所以这一章的核心思想就是四个字:先窄后宽。不要说完产品形态就启动编码,而是按照现实中技术团队开发一个需求的流程,与AI配合,一步一步搭建出最终的形态。

1. 选择生态最成熟的技术

面对琳琅满目的技术名词,你可能会被一些听起来酷炫的小众框架吸引,觉得那是更高级的选择。但在 VibeCoding 这个环境中,技术的“酷”远不如“稳”重要。

成熟生态意味着三件事:

  • AI 的训练数据里这种代码最多,它写起来最熟练;
  • 网上任何报错信息几乎都有人遇到过、解决过,你复制粘贴就能自救;
  • 当你卡住时,随便问一个懂点技术的朋友或者继续追问 AI,都能获得可理解的答案。

所以不要让自己的产品从技术选型开始就进入困难模式。你的第一句提示词可以这样写:

我要做一个记账本微信小程序,或者一个 Web 网站,或者一个桌面应用,请为我选择一个生态最成熟、报错最容易解决、社区讨论最多的技术栈,并给出理由。

把这段话当作你的第一道安全锁。AI 给出建议后,你只需要确认它选的是不是那种”大家都用过、教程一搜一大把”的方案,比如 React、Vue、微信原生开发、Python 加 Flask 或 Electron 这类老牌选手。即便你完全不懂它们是什么,也至少确保自己这次开发任务走在了坑最少的路线上。

举个实际例子。当你向 AI 提出上面那句话之后,你可能会收到这样的回复:

AI 推荐方案:React + Vite + Tailwind CSS

推荐理由:

  • React:全球使用率最高的前端框架,GitHub 上拥有 230k+ star,社区极其活跃。任何你遇到的问题几乎都有人遇到过并已解决。
  • Vite:新一代前端构建工具,零配置即可启动项目,开发服务器秒开,对新手极其友好。
  • Tailwind CSS:原子化 CSS 框架,不需要你会写样式,直接在 HTML 中加类名就能做出漂亮的界面。

总结:这套组合是目前 Web 开发领域生态最成熟、教程覆盖最广、AI 训练数据最多的技术栈之一,非常适合 VibeCoding。

AI 选好技术栈后,你只需要一行命令就能让整个项目骨架出现在眼前:

npm create vite@latest my-bill-app -- --template react

AI 会帮你生成这样一个干净的项目结构:

my-bill-app/
├── index.html          # 网站的入口文件
├── package.json        # 项目的配置文件(记录依赖、脚本等)
├── vite.config.js      # Vite 的配置文件(通常不需要动)
├── src/
│   ├── App.jsx         # 你的主要代码就写在这里
│   ├── App.css         # 样式文件
│   ├── main.jsx        # 程序的启动入口(不需要动)
│   └── index.css       # 全局样式
└── public/             # 放图片、图标等静态资源

每个文件职责清晰,你接下来绝大部分时间只需要和 App.jsx 打交道。这就是一条被无数人踩过的、最平坦的路。

跑通最小闭环

选定技术栈之后,绝对不要急着去写登录注册、数据库连接或者漂亮的主页。你在这个阶段的唯一目标,就是让这个 AI 生成的空壳程序在你的电脑上 真正地跑起来

它可以是网页上弹出一句 Hello World,可以是小程序开发工具里出现一个空白页面,也可以是桌面应用弹出一个带标题的空窗口。无论形态多寒酸,只要它是由你亲自启动,并在你的屏幕上按照预期渲染出来的,这就是一次伟大的胜利。

为此,你应该要求 AI 给你一步步的配置指南,细致到怎么安装环境、怎么打开终端、怎么输入命令、怎么把运行结果截图给你看。哪怕这个过程看起来像在机械地复制粘贴,也请耐心做完。这相当于在给后续所有开发工作打地基。很多非程序员在这一步因为环境报错而产生强烈的挫败感,误以为是自己操作失误。其实恰恰相反,环境配置本身就是整个开发链路中变数最多的一环,不同的操作系统、不同的软件版本都会引发奇怪的问题。正因如此,才更需要让 AI 帮你把这条路先走通,而不是让它去写那些花哨的功能代码。事实上,现在的编程工程工具,都可以帮你走这一步,在背后运行命令自己做环境验证,失败后会分析报错信息尝试其他配置方案。

如果环境没跑通,后面写再多完美的代码都是废纸,因为你连看都看不到它们运行起来的样子。空壳一旦跑通,你就获得了一个可以随时把新功能装进去的活体容器。接下来哪怕每次只加一个按钮、一个输入框,你都能立刻看到反馈,这种即时的正反馈正是 VibeCoding 最迷人的地方。反之,如果一开始就带着几十个功能一起上,一旦出错,你根本不知道是环境的问题、代码的问题,还是 AI 理解错误,排查的难度会让整个项目迅速烂尾。

让一个最简陋的空壳运行起来。这步走稳了,后面的一切才有了真正生长的土壤。

以我们的记账本项目为例,整个最小闭环的搭建过程就是下面这几步。你可以把这些命令直接复制给 AI,让它帮你逐条解释,然后一条一条执行:

# 第一步:确保你的电脑上有 Node.js(AI 会教你安装)
node --version

# 第二步:用 Vite 创建项目骨架(一行命令搞定)
npm create vite@latest my-bill-app -- --template react

# 第三步:进入项目目录
cd my-bill-app

# 第四步:安装项目依赖(AI 已经帮你列好了需要的库)
npm install

# 第五步:启动开发服务器
npm run dev

执行完最后一步,你的终端会显示这样一行输出:

  VITE v5.x.x  ready in 350 ms

  ➜  Local:   http://localhost:5173/

用浏览器打开 http://localhost:5173/,你会看到一个带有 React 图标和计数器的默认页面。这就是你的第一个里程碑。 此时你的项目里只有这些真正在起作用的文件:

my-bill-app/
├── index.html
├── package.json
├── vite.config.js
└── src/
    ├── App.jsx      # ← 以后你的代码就写在这里
    ├── App.css
    ├── main.jsx
    └── index.css

和上一节的项目结构一模一样,没有任何多余的东西。空壳跑通了,接下来每写一行新代码,你都能在浏览器里立刻看到效果。

二、小步快跑,只实现“少量核心模块”

环境跑通之后,你手上有了一个活着的空壳,这时候想要往里填东西的冲动会非常强烈。但请克制住一口气把所有功能都告诉 AI 的念头。第二章要解决的核心问题就是如何从零开始堆积功能,同时让整个开发过程始终处于你能够掌控的范围。最有效的策略是小步快跑,也就是一次只实现一个极其微小的功能,做完之后立刻动手验证,确认它真的能用,再继续下一个。

这个原则听起来简单,做起来却容易被遗忘。因为人在有了一个跑起来的界面之后,很容易产生“不如我让 AI 顺便把登录、存储、动画一起做了”的贪心想法。但你要意识到,VibeCoding 里的 AI 就像一位记忆力有限的搭档,你交给它的任务越集中、约束越少,它给出的代码就越不容易出错。一旦你试图一步到位,把好几个并不相关的逻辑塞进同一个提示里,AI 极有可能在各部分之间产生隐含的冲突,而你却很难定位问题到底出在哪儿。

1. 切蛋糕思维

很多人对功能的想象是一整块蛋糕,总想一口气吞下去。比如你想要一个带记账功能的待办清单,脑袋里出现的可能是“帮我写一个记账待办清单应用”。这句话对 AI 来说太过庞大了,它会同时试图处理界面布局、数据存储、计算余额、交互逻辑等一系列事情,最终产出的很可能是一团无法运行的乱麻。

正确的做法是把这块蛋糕切成能一口吃下的小片。你可以把刚才那个需求拆成两个极其纯粹的步骤,甚至更细。第一步只要求实现能输入文字并显示在列表上,其他什么都不要。这意味着 AI 只需要写一个输入框和一个展示列表,不需要考虑删除、编辑、持久化或任何运算。你拿到代码后立即运行,确认输入文字确实能出现在列表中,这一步才算真正完成。第二步再去要求实现点击按钮能删除这条记录。此时 AI 只需要在已有的能显示列表的基础上加入删除功能,改动范围很小,出错的概率大大降低。

下面是我们的极简记账本按照切蛋糕方式演进的实际过程。每一步只做一件事,每一步的代码都是可运行的状态。

第一步:只做输入+列表展示。 你对 AI 说:”请在 App.jsx 中实现一个输入框,输入文字后按回车,文字出现在下方的列表中。不要做删除、不要做存储、不要做任何额外功能。”

AI 会生成类似这样的代码:

// App.jsx — 第一步:只有输入和展示
import { useState } from 'react';

function App() {
  const [text, setText] = useState('');
  const [items, setItems] = useState([]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      setItems([...items, text]);
      setText('');
    }
  };

  return (
    <div>
      <h1>极简记账本</h1>
      <form onSubmit={handleSubmit}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="输入一条记录"
        />
        <button type="submit">添加</button>
      </form>
      <ul>
        {items.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

运行 npm run dev,在输入框里打几个字,按回车,文字立刻出现在下方。此时你的项目结构依然极简:

src/
├── App.jsx      # 只有这一个文件被修改过
├── App.css
├── main.jsx
└── index.css

第二步:增加删除功能。 你确认上一步没问题后,再对 AI 说:”请在上面的代码基础上,给每条记录后面加一个删除按钮,点击后该记录从列表中移除。”

AI 只会在已有的 items.map 里多加一个按钮,改动范围很小:

// 仅展示改动部分 — 在 <li> 中增加删除按钮
<ul>
  {items.map((item, i) => (
    <li key={i}>
      {item}
      <button onClick={() => {
        setItems(items.filter((_, index) => index !== i));
      }}>删除</button>
    </li>
  ))}
</ul>

第三步:增加金额输入和余额计算。 你对 AI 说:”现在每条记录除了文字还要包含金额(数字),在列表顶部显示总金额。请只修改 App.jsx,不要引入新文件。”

AI 会把 text 扩展为 {text, amount} 的对象,加上一个金额输入框,然后在顶部计算总和。代码变多了,但仍在一个文件内,逻辑完全在你可控的范围内。

每一步做完、跑通、确认,再走下一步。三次迭代之后,你的 App.jsx 从 20 行变成了约 50 行,但始终只有一个文件,项目结构没有膨胀。

这种切蛋糕思维本质上是在人为地控制每一次 AI 生成的复杂度。每完成一小片,你都可以亲手验证它的实际表现,一旦哪里不对劲,你马上就能知道是刚才那一步引入的问题。它让调试从一个让人绝望的全黑盒子变成一个可以逐步排查的透明过程。

2. 每次修改都要看到反馈

切碎功能只是手段,核心在于验证闭环。每让 AI 帮你写完一个微小功能,不要急着告诉它“接下来再帮我加一个 XXX”,而是立刻在电脑上运行一遍。看一眼界面,确认没有报错,操作一下看效果是否符合预期。这个过程只需要几十秒,却能帮你节省未来可能耗费好几个小时的大范围排查工作。

这样做有两个直接的好处。第一,错误的存活时间极短。如果上一个功能是好的,加完新代码后一运行就崩了,你百分之百可以断定是刚才加的这段代码搞砸了,马上让 AI 修复它。第二,你获得的是一种连续的、实实在在的正反馈。每运行成功一次,你对整个项目的掌控感和信心就增加一点。这种稳步推进的节奏远比埋头写了一大堆代码然后一次性运行看到满屏报错要健康得多,也更容易让你坚持把项目做完。

以我们上面第三步(增加金额计算)为例,你完整的验证闭环就是这样一个极短的清单:

✓ 打开浏览器 http://localhost:5173/ — 页面正常显示,没有白屏
✓ 输入"午餐"、金额"35",点添加 — 列表中出现了"午餐  ¥35"
✓ 输入"咖啡"、金额"18",点添加 — 列表顶部余额显示 ¥53
✓ 点击"咖啡"旁边的删除按钮 — 余额变回 ¥35
✓ 刷新页面 — 数据还在(或不在,都可以接受,下一步再加持久化)

整个过程不到一分钟。五个勾全部打上,你就可以放心地进入下一个功能。如果哪一个勾没打上,你知道问题就出在上一次改动里,直接把上面的代码和错误现象一起扔给 AI,让它定位修复。你不必自己看懂报错信息,只需要做那个”按回车运行、看结果、告诉 AI 哪里不对”的人。

小步快跑的本质就是用可控的节奏换取持续的确定性。每次只添加一个功能,每次添加之后马上验证,你的项目就像一个不断搭积木的过程,每搭一块就确认它稳稳地立在上面。即便后来需要调整,你也知道自己稳稳地站在什么地方。

三、推倒重来,按新架构重构一遍

当一小片一小片的功能逐步累积,你手上可能已经有了几个能跑通的核心模块。但此时的代码多半是凑合着堆在一起的,所有逻辑可能挤在一两个文件里,看上去混乱而脆弱。这时候如果继续往上叠加功能,维护成本会急剧上升,任何小改动都可能牵一发而动全身。 所以必须做一次彻底的整理,这正是专业工程师常说的重构

重构的通俗理解不是把代码删掉重写,而是保持所有功能不变,把代码内部的摆放方式重新规划一遍,让它变得规整、清晰、便于以后继续添加新东西。你在第一章和第二章所获得的那些功能,此刻就是你的全部家当,而重构就是要为它们建一座结构合理的房子来安放。

1. 搬家与规范化

你可以请 AI 根据当前已经实现的所有功能,生成一份新的架构文档。然后要求它按照这个新架构,帮你把现有的代码搬到不同的位置。比如把专门负责界面展示的代码放进 UI 文件夹,把处理数据计算和逻辑的部分放进 Data 文件夹,把一些通用工具抽出来放在 Utils 里。这种分家的过程会让每个文件只承担一类明确的职责,以后再想修改界面,你大概知道应该去 UI 文件夹里找,想调整计算逻辑就去 Data 文件夹。

这个过程对于非专业程序员来说可能会有些抽象,你不需要一次性理解所有名词,只要理解一个原则就行:让相关联的代码住在一起,让不同职责的代码互相隔开。AI 完全可以替你完成这个搬家动作,它会按照你的指令把代码切分好,并确保各个文件之间的引用关系依然正确。搬完之后,别忘了运行一次你的程序,确认所有功能都还能正常运作。这就等于完成了一次房屋改造,里面的东西一样没少,但格局焕然一新。

以我们的极简记账本为例,重构前你的所有代码都挤在一起:

# 重构前 — 所有逻辑堆在一个文件里
src/
├── App.jsx       # 200+ 行,界面+数据+逻辑全混在一起
├── App.css       # 样式也全部堆在一个文件里
├── main.jsx
└── index.css

AI 帮你重构之后,同样的功能会被重新分配到各司其职的位置:

# 重构后 — 职责分离,结构清晰
src/
├── components/
│   ├── TransactionForm.jsx    # 专门负责输入表单的界面
│   ├── TransactionList.jsx    # 专门负责列表展示的界面
│   └── BalanceSummary.jsx     # 专门负责余额计算的展示
├── hooks/
│   └── useTransactions.js     # 全部数据逻辑(增删改查)集中在这里
├── utils/
│   └── formatCurrency.js      # 金额格式化这样的通用工具
├── App.jsx                    # 现在只有 ~25 行,只负责组装各个组件
├── App.css
├── main.jsx
└── index.css

代码数量没变,功能完全一样,但每个文件现在只承担一件事。你以后想改界面就去 components/,想调计算逻辑就去 hooks/,改任何一个地方都不用担心无意中破坏另一个无关的功能。

2. 创建编程工具的Harness约束文档

架构清晰之后,真正的挑战才刚开始。你当然可以反复提醒 AI “请遵守我们的架构”,但 AI 的记忆有限,一旦对话变长或开启新会话,它很可能又把界面逻辑和数据运算搅在一起,让你前功尽弃。因此,你必须把刚刚生成的架构说明固化成一份架构约束文档,并 强制要求你使用的 AI 编程工具在真正动代码之前,必须完整阅读这份文档

这份文档就是你的 Harness。它像缰绳一样勒住 AI,提前框定什么能做、什么绝不能做、代码必须落在哪个文件夹里。你可以直接让 AI 帮你生成这份文档,只需对它说:根据我们已经完成的重构结构,请生成一份架构约束文档,内容需包含每个文件夹的职责、组件和数据流的边界、命名规范,以及禁止使用的跨层引用。把这份文档保存为项目根目录下的一个固定文件,比如 AI_RULES.md。这样你就拥有了一份白纸黑字的合同。

以下是一份可以直接复制使用的 AI_RULES.md 范例,它就是为上面那个重构后的记账本项目量身定制的:

# AI_RULES.md — 极简记账本架构约束

## 项目概述
基于 React + Vite 的记账本 Web 应用,使用 Tailwind CSS 做样式。

## 文件夹职责
- `src/components/` — 纯 UI 组件,只负责渲染界面,不直接操作数据。每个组件一个文件。
- `src/hooks/` — 自定义 Hook,封装所有数据逻辑(增删改查、计算、持久化)。组件通过调用 Hook 获取数据和操作方法。
- `src/utils/` — 纯函数工具,不依赖 React,可被任何文件引用。
- `src/App.jsx` — 根组件,只负责组装各个子组件,不包含业务逻辑。

## 数据流规则(单向数据流)
1. `useTransactions` Hook 是数据的唯一源头(Single Source of Truth)。
2. 子组件通过 props 接收数据,通过回调函数触发操作,绝不直接修改 Hook 内部状态。
3. 组件之间不直接传递数据,所有共享数据通过 App.jsx 中转。

## 命名规范
- 组件文件:PascalCase(如 `TransactionForm.jsx`- Hook 文件:`use` 前缀 + CamelCase(如 `useTransactions.js`- 工具函数:CamelCase(如 `formatCurrency.js`## 禁止事项
- 禁止在 `components/` 中直接使用 `localStorage``fetch`
- 禁止在 `hooks/` 中引入 JSX 或 UI 库
- 禁止跨层级引用:`components/` 之间不能互相 import
- 禁止引入未在 `package.json` 中声明的第三方库

接下来是关键一步,让你的工具真正执行这份合同。

如果你使用 Cursor,可以把这份文档配置为项目规则。在 Cursor Settings 的 Rules 中,将 AI_RULES.md 添加进去并设为自动应用。此后每次你提出需求,Cursor 都会自动把这份约束注入上下文,AI 在构思代码之前就已经被这些规则包围,生成的代码自然会落在正确的文件夹里,遵循你定好的边界。

类似的:

如果你使用 Claude Code,可以在项目根目录创建一个 CLAUDE.md 文件,在里面写明一条铁律:在生成任何代码之前,必须先重新读取并严格遵守 AI_RULES.md 中的所有架构约束。你也可以在每次重要对话开头直接对 Claude Code 说:”请先读取根目录下的 AI_RULES.md,本轮所有代码生成都必须遵守其中的规定。”一旦养成习惯,你甚至可以把这条指令封装成一个自定义斜杠命令,一键触发。

你的 CLAUDE.md 内容可以非常简短,比如这样:

# CLAUDE.md

## 架构约束
在生成或修改任何代码之前,你必须先读取项目根目录下的 `AI_RULES.md` 文件,
并严格遵守其中定义的所有架构约束。包括但不限于:
- 文件夹职责划分
- 单向数据流规则
- 命名规范
- 禁止事项清单

如果 AI_RULES.md 中的规则与你的默认行为冲突,以 AI_RULES.md 为准。

这样每次新开会话,Claude Code 都会自动加载这份指令,带着镣铐开始工作。

这样做带来的改变是根本性的。过去你是等 AI 写完代码再去检查它有没有把东西放错地方,现在 AI 在动手之前就已经戴上了镣铐。它会自觉把界面代码写进 UI 文件夹,把数据处理逻辑写进 Data 文件夹,不会擅自引入你没允许的库,也不会让不同层级之间的引用乱飞。这种前置约束,远比出错后再返工高效得多。

四、戴着镣铐跳舞,持续进化

完成了重构并锁定了架构约束,你现在拥有的是一个极其坚固且规范的基地。所有旧功能都安稳地待在正确的文件夹里,自动化测试随时可以帮你站岗放哨,AI 在动手之前就必须阅读你的架构文档。

到了这个阶段,你终于可以放心地进入持续进化的节奏,去开发第四个、第五个、甚至第一百个功能。

这里的镣铐不是一个贬义词。恰恰相反,正因为提前给 AI 戴上了这副镣铐,你才获得了真正的自由。你不需要再担心它会把代码写乱,不需要每次都亲自检查文件结构,也不需要在每一次改动后战战兢兢地手动点遍所有功能。镣铐把 AI 的创造力圈定在你可以掌控的范围内,让它在你画好的框里尽情发挥。

在这个阶段,你与 AI 的每一次互动都应该遵循一个固定的循环。这个循环只有四步,每一步都对应一个明确的目的,形成一条不可打断的链条。下面以我们的记账本项目为例,演示如何用这个循环来添加一个”导出报表”功能。

第一步:明确边界。 每次提出新需求时,不要只说”我要添加什么功能”,而是要带上一个前置条件。你可以直接把下面这句话当作模板来用:

我想添加一个”导出为 CSV 报表”的功能:将当前所有记账记录导出为一个 CSV 文件并触发浏览器下载。请严格按照 AI_RULES.md 规定的架构约束来设计,新代码放在对应的文件夹下,不引入新的跨层依赖,不安装新的第三方库。

这句话的意义在于,它强迫 AI 在构思解决方案之前,先认领你的规则。即便工具已经自动注入了约束文档,你再次口头强调一遍,也能有效提升 AI 遵守规则的几率。

第二步:代码注入。 AI 生成代码之后,你需要扫一眼它是否落在了正确的位置。以导出报表功能为例,AI 执行后,你的项目结构会变成这样:

src/
├── components/
│   ├── TransactionForm.jsx
│   ├── TransactionList.jsx
│   ├── BalanceSummary.jsx
│   └── ExportButton.jsx         # ← 新增:导出按钮组件
├── hooks/
│   └── useTransactions.js
├── utils/
│   ├── formatCurrency.js
│   └── exportToCsv.js           # ← 新增:CSV 导出逻辑
├── App.jsx
├── App.css
├── main.jsx
└── index.css

新增的文件放在了正确的位置——按钮在 components/,导出逻辑在 utils/。几秒钟就能确认,但这一步能拦住”AI 把数据处理写进 UI 组件”这类低级错误。

第三步:拉紧马具。 代码放好之后,立刻运行你的测试。即使你不会写测试,也可以让 AI 帮你生成一份。比如在重构阶段,你就可以对 AI 说:”请为现有的增删改查功能生成一套自动化测试,放在 src/__tests__/ 目录下,确保我每次加新功能时可以一键检查有没有破坏旧功能。”之后每次写完新代码,只需要在终端运行一行命令:

npm run test

你会看到类似这样的输出:

✓ renders transaction list correctly
✓ adds a new transaction on form submit
✓ deletes a transaction on button click
✓ calculates balance correctly
✓ ExportButton renders without crashing
✓ exportToCsv generates valid CSV format

Tests: 6 passed, 6 total

绿色全部通过 → 放心前进。如果出现红色,直接把错误信息复制粘贴给 AI:”新增导出功能后,第三个测试挂了,请在不破坏现有架构的前提下修复。”你不用理解错误的底层原理,测试就是你的客观裁判。

第四步:人工验证。 测试通过之后,花一分钟亲手用一下。对于导出报表这个功能,你的验证清单就是:

✓ 点击"导出 CSV"按钮 → 浏览器弹出文件下载
✓ 用 Excel 打开下载的文件 → 中文不乱码,金额格式正确
✓ 删除一条记录后再导出 → 导出的文件不包含已删除的记录
✓ 页面其他功能正常 → 添加、删除、余额计算都没有受影响

四个勾全部打上,这一轮循环才算真正闭合。现在你可以放心地对 AI 说:”下一个功能。”

这四个步骤合在一起,就是一个持续进化的发动机。你每跑完一圈,就稳妥地新增一个功能,项目在可控的状态下稳步长大。如果按照本文的方法一路走下来,当初那个只有 App.jsx 一个文件的 Hello World 空壳,最终会成长为这样一个结构清晰的中型项目:

my-bill-app/
├── AI_RULES.md              # 架构约束(AI 的缰绳)
├── CLAUDE.md                # Claude Code 入口指令
├── index.html
├── package.json
├── vite.config.js
└── src/
    ├── components/          # 界面层:每个组件一个文件
    │   ├── TransactionForm.jsx
    │   ├── TransactionList.jsx
    │   ├── BalanceSummary.jsx
    │   └── ExportButton.jsx
    ├── hooks/               # 数据层:所有业务逻辑
    │   └── useTransactions.js
    ├── utils/               # 工具层:纯函数
    │   ├── formatCurrency.js
    │   └── exportToCsv.js
    ├── __tests__/           # 自动化测试:你的安全网
    │   └── app.test.js
    ├── App.jsx
    ├── App.css
    ├── main.jsx
    └── index.css

从始至终,你没有写过一行从零开始的代码——所有的代码都是 AI 生成的。但你做的每一步决策——选成熟技术、跑通最小闭环、切碎功能逐个验证、推倒重构、用 Harness 约束 AI——这些才是 VibeCoding 真正的核心能力。代码是 AI 写的,但软件是你做的。

这种节奏也许听起来不够刺激,但正是这种不刺激的节奏,能让一个非专业程序员把项目从想法一路推进到真正可用的产品。镣铐从一开始就是你的盟友,它让你戴着它跳舞,而不是在失控的狂奔中跌倒。

【AI】Harness Engineering工程化方法总结

【AI】Harness Engineering工程化方法总结

本文旨在介绍Harness Engineering的概念演化由来

从22年的GPT,及之后生成式人工智能爆发至今,大语言模型的底层能力已经完成了一次次跨越式的蜕变。然而在产业落地、尤其是智能体开发的前沿演进中,越来越多的人发现:决定 AI 应用最终成败的,往往不仅是那个博古通今的“模型大脑”,更是围绕它搭建的系统工程。

如果把大模型比作一匹拥有无穷神力的“旷野烈马”,那么我们要在现实世界中驾驭它走向既定终点,就必须演进出三套截然不同、却互为犄角的底层工程学科:

  • 提示词工程(Prompt Engineering): 研究如何用最精准、最具逻辑的指令,唤醒 AI 的深度思考能力;
  • 上下文工程(Context Engineering): 动态调整的“视野与记忆”,研究如何跨越海量信息与高昂成本的物理限制,在万级 Token 中动态提炼出最关键的业务背景与知识输入;
  • 脚手架工程(Harness Engineering): 它是确保绝对安全的“缰绳与防具”,研究如何在大模型非确定性的提议下,构建一个隔离沙箱与自动化反馈的闭环系统,确保 AI 的行动绝对可靠。

从探索 AI 能理解什么(Prompt),到管理 AI 需要知道什么(Context),再到约束 AI 被允许做什么(Harness),这三大热门技术正在构建起 AI Agent 时代的全新技术版图。本文将带你逐一拆解这三个核心概念,探寻大模型走向工业级生产力的真正秘密。

Harness Engineering

过去两年,很多人认为 AI 能不能完成任务,取决于 Prompt 写得够不够好。后来大家发现,仅靠 Prompt 已经远远不够。于是又出现了 Context Engineering——如何给 AI 提供正确的上下文。而到了 2026 年,OpenAI 在介绍 Codex 内部研发方式时,首次提出了一个新的概念:

Harness Engineering

它代表着 AI Agent 时代的软件开发方式,开始发生根本性的变化。

什么是 Harness?

Harness 这个词原本来自英文。最初的意思是马具、缰绳、安全带。它最大的作用不是提供动力,而是表示一种约束、控制、引导。一匹马本身拥有动力,但是如果没有缰绳来控制,它就无法完成运输任务。

同样的,LLM拥有很强的推理能力。但是没有 Harness,它也只是一个会回答问题,和人对话的大模型。所以很多人现在开始使用下面这个公式:

AI Agent = LLM + Harness

其中:

LLM 提供的是:

  • 推理能力(Reasoning)
  • 编码能力(Coding)
  • 理解能力(Understanding)

Harness 提供的是:

  • 工具
  • 权限
  • 环境
  • 上下文
  • 验证
  • 反馈
  • 记忆
  • 工作流

真正让 Agent 能工作的,不只是模型,而是整个运行系统。

Context Engineering 和 Prompt Engineering 的局限性

假设现在有一个典型的大型 Android 项目:

app/
feature-home/
feature-login/
feature-profile/
feature-order/
common/
network/
storage/
ui/
compose/
...

总代码:

250万 LOC

Module:120+

开发人员:60+

CI:GitHub Actions

测试:
Unit Test
Instrumentation Test
Compose Test

你的需求只有一句:

修复首页点击商品偶尔闪退的问题。

对于 AI 来说,它需要完成找到 Crash,分析原因,找到代码,修改,运行测试,验证,提交 PR

但是不同阶段,AI 的能力完全不同。Prompt Engineering 的核心思想就是 把需求写清楚。

例如:

你是一位资深 Android 工程师。

请帮我修复首页商品点击闪退的问题。

请遵循 Kotlin 官方编码规范。

不要修改无关代码。

或者再高级一点:

请采用 MVVM 架构。

不要影响其他 Feature。

修改完成以后给我 Diff。

Prompt 已经写得很好,但是问题来了,AI 根本不知道:

首页代码在哪里?

哪个 Module?

哪个 Activity?

哪个 ViewModel?

哪个 Repository?

哪个接口?

哪个 Crash?

Prompt 写的再漂亮,AI 还是不知道去哪找。Prompt Engineering最大的问题就是:

它只能告诉 AI “应该做什么”,不能告诉 AI “在哪里做”。

Android 大项目里:

app/
feature-home/
feature-home-new/
feature-home-v2/
feature-feed/
feature-discover/
feature-video/

Prompt 不可能告诉 AI:

首页其实在:
feature-feed
而不是:
feature-home

更不可能告诉 AI:

真正 Crash 的代码
是在 common-image 库。

在实际项目应用中,Prompt 最大的问题是没有工程信息。只能由使用人先自己理解一遍,再将疑难点通过Prompt优化后,提供给AI,获取解决方案,自行导入。

第二阶段:Context Engineering

大家发现光有 Prompt 不够时,于是开始想办法提供 Context。

例如:

README

Architecture.md

Coding Style

API 文档

最近修改记录

相关源码

Git Diff

Issue

Crash 日志

现在 Prompt 变成 Prompt + Context ,例如:

下面是:
HomeViewModel
ProductRepository
Crash 日志
请修复。

AI 的能力立刻提升很多。可以阅读、分析、修改。这就是 Context Engineering。

问题又来了。大型 Android 项目不是几个文件。而可能是250万的代码,AI 不可能一次读取全部代码到上下文。对于小点的项目,即使可以一次对话获取全部信息,也会产生巨大的噪声信息,让AI模型抓不到重点。还会出现新的问题:Context Window 不够。例如:

HomeFragment
↓
调用
HomeViewModel
↓
调用
Repository
↓
调用
Network
↓
调用
Cache
↓
调用
Room
↓
调用
Common Library

真正 Bug 可能在Common Library中,但是Context 里没有。AI 根本不知道,AI 只能猜。

还有一个问题:

Context 是静态的。给 AI 10 个源码文件,AI 修改以后。下一步怎么办?

不知道。

因为 Context 不会自动更新。AI 也不会自己继续获取。

所以Context Engineering 最大的问题就是:

Context 是一次性提供的。

AI不会继续找代码,也不会继续搜索,继续阅读,继续验证

第三阶段:Harness Engineering

Harness 的思想完全不同,它的信息传递的理念是

不要一次把 Context 全给 AI。

而是让 AI 自己获取。

例如:

AI 收到:

修复首页 Crash。

第一步:

AI 自己执行:

grep
rg
find
git
ls

搜索:

Home
Product
Crash

然后发现报错在 feature-feed 这个模组,则会继续打开源码,进一步发现在ProductViewModel这个类的loadProduct() 方法,然后打开 Repository,查看网络请求。直到找到真正 Crash。

这里最大的变化就是:

Context 不再是”喂给 AI”,而是 AI 主动去获取。

其实真正的大变化其实在后面。AI 修改代码以后:

它不会说:

Done.

而是继续:

./gradlew test

发现:

Build Failed

继续:

读取错误日志

修改。

继续:

./gradlew assembleDebug

发现:

Lint Error

继续:

修改

然后:

运行 Unit Test

最后:

运行 Compose Test

最后:

全部通过。

这整个循环:

写代码
↓
编译
↓
测试
↓
修复
↓
重新测试
↓
提交

就是 Harness。

把 AI 看成一位刚加入团队的新 Android 工程师:

Prompt Engineering:你只告诉他:“修一下首页闪退。” Context Engineering:你再把设计文档、Crash 日志、相关源码打印出来交给他。 Harness Engineering:你不仅告诉他目标,还给了他 IDE、Git 权限、终端、Gradle、ADB、模拟器、CI、日志平台以及代码搜索工具,并允许他自主查阅文档、运行测试、反复验证直到修复完成。

前两者更像是在回答问题(Question Answering);Harness Engineering 则是把 AI 放进一个真实的软件工程环境,让它像一名工程师一样,通过”观察 → 分析 → 执行 → 验证 → 反馈”的闭环完成整个开发任务。这也是它与 Prompt Engineering、Context Engineering 最本质的区别。

Harness 到底包含哪些东西?

OpenAI 实际上做的事情,不是让 Codex 更聪明。而是不断完善 Codex 周围的”基础设施”。可以把 Harness 看成下面这张结构图:

                 Human

                    │

             High Level Goal

                    │

          +-------------------+
          |   Harness Layer   |
          +-------------------+

      Prompt
      Context
      Memory
      Tool Calling
      Permissions
      File System
      Git
      Terminal
      Browser
      Logs
      Metrics
      CI
      Test
      Review
      Retry
      Verification

                    │

                  LLM

                    │

               Generated Code

可以发现真正复杂的已经不是 Prompt,而是 Harness的架构。

Harness Engineering 的核心思想

一句话概括:

不要教 AI 怎么写代码,而是让 AI 能够自己完成整个开发流程。

OpenAI 在内部几乎不再人工写代码,而是让 Codex:

读取需求
↓
阅读代码
↓
分析影响范围
↓
修改代码
↓
生成测试
↓
运行测试
↓
查看日志
↓
修复 Bug
↓
重新测试
↓
提交 PR
↓
AI Review
↓
继续修改
↓
直到全部通过

整个过程形成闭环。人类负责只制定目标。Agent 负责执行目标。

工程师的角色正在变化

这是 OpenAI 提出的一个非常重要的观点。以前的软件工程师:

需求
↓
自己写代码
↓
测试
↓
上线

未来的软件工程师:

需求
↓
设计 Harness
↓
定义规则
↓
设计反馈
↓
Agent 自动开发

工程师不再是:

Code Producer(代码生产者)

而更像:

System Designer(系统设计者)

也就是让 AI 能够稳定工作的工程师。

Harness 的几个核心组成

1. Context System(上下文系统)

AI 最大的问题不是不会写,而是不知道:

哪些代码最重要?

因此需要:

代码索引

知识库

Architecture Docs

AGENTS.md

Coding Rules

Dependency Graph

OpenAI 提到,他们没有把所有内容塞进一个巨大的 AGENTS.md 文件,而是尽量让知识分层、按需提供,让 Agent 获得的是”地图”,而不是一本几百页的说明书。

2. Tool System(工具系统)

Agent 不应该只能聊天。

它应该拥有:

Git

Shell

IDE

Browser

Debugger

Docker

CI

GitHub

Jira

Slack

这些工具全部开放给 AI。

AI 自己调用。

而不是人复制粘贴。

3. Feedback System(反馈系统)

AI 最大的问题:

不知道自己错了。

Harness 就负责告诉它:

编译失败

↓

测试失败

↓

日志异常

↓

页面打不开

↓

性能下降

↓

重新修改

整个系统形成:

Observe

↓

Analyze

↓

Fix

↓

Verify

↓

Repeat

OpenAI 甚至让 Agent 能够读取日志、指标和 Trace,并利用浏览器自动操作 UI 来复现问题、验证修复效果,而不是只依赖静态代码分析。

4. Verification(验证系统)

很多 AI Coding 最大的问题:

它说:

Done.

实际上:

不能运行。

Harness 会定义:

必须:

Build Success

+

Unit Test Pass

+

E2E Pass

+

Lint Pass

+

Security Pass

否则:

不能结束任务。

5. Observability(可观测性)

这是 OpenAI 花了大量篇幅介绍的一部分。他们让 Agent 可以查看:

Log
Metric
Trace
Chrome
DOM
Screenshot
Performance

于是AI 不只是会写。还能自己 Debug。自己定位问题。自己验证修复。

总结

对于Prompt Engineering 和 Context Engineering,Harness 并没有取代前两者,而是把它们全部包含进去。

模型之间的能力差距正在不断缩小。GPT、Claude、Gemini、Qwen 等模型,在很多编码任务上的基础能力已经越来越接近。

真正决定 Agent 是否可靠的,往往不是模型本身,而是模型所处的运行环境:上下文如何组织、工具如何接入、权限如何控制、失败后如何恢复、结果如何验证。社区越来越多的实践也在强调:”更好的 Harness 往往比更换模型带来的收益更大。所以 Harness Engineering 并不是一种新的 AI 模型,也不是一种新的 Prompt 技巧。它是一种面向 AI Agent 的软件工程方法论,其核心目标是为智能体构建一个可观察、可验证、可反馈、可控制的运行环境。

未来的软件开发,竞争的重点将不再只是”谁拥有更强的大模型”,而是谁能够设计出更优秀的 Harness。在 Agent 时代,工程师创造价值的方式,也将从”亲自编写代码”逐渐转向”设计能够持续放大 AI 能力的系统”。

【AI】ClaudeCode机制规则记录

【AI】ClaudeCode机制规则记录

本文旨在介绍使用ClaudeCode时的一些规则类的背景知识的记录,还有提效指南,在日常使用中常用常新

提效指南

为你的文章整理了一份关于 Claude Code 快捷操作和效率技巧的详细指南,你可以根据文章结构和侧重点进行取舍和编排。


斜杠命令:你的“快捷控制面板”

斜杠命令(Slash Commands)是 Claude Code 的核心交互方式,在对话中输入 / 即可呼出命令列表。根据功能,我们可以将它们分为几大类。

⚙️ 项目管理:为 AI 建立“长期记忆”

  • /init:新项目的第一步。这个命令会扫描你的代码库,自动生成一份 CLAUDE.md 文件,作为Claude理解项目的“说明书”,包含技术栈、架构和规范等信息。后续所有操作都会参考这个文件,准确度会高很多。
  • /memory:直接编辑 CLAUDE.md 记忆文件。无需手动打开文件,你可以在对话中直接说“更新记忆,所有测试都用 Vitest”,Claude 会通过此命令帮你完成修改。Claude犯过的错,都可以通过这个命令沉淀成规则。
  • /add-dir:在 Monorepo 等大型项目中,用此命令手动添加其他工作目录到Claude的“视野”中,确保上下文覆盖所有相关代码。

🕵️‍♂️ 分析与洞察:做项目的“X光机”

  • /context:一个“上下文透视镜”。输入后,它会清晰列出当前上下文窗口的组成——系统提示词、规则文件、对话历史等各自占用了多少Token。能帮你精准定位是谁“吃掉”了宝贵的上下文空间。
  • /insights:生成一份HTML报告,分析你过去30天的使用数据,揭示你在哪些任务上经常卡壳、需要反复纠正,并给出优化工作流的建议。
  • /stats:查看你的个人使用仪表盘,包括使用习惯、最爱的模型、连续使用天数等趣味数据。
  • /cost/usage:前者显示当前会话消耗的Token和费用,后者查看API使用额度和剩余量,帮你做好成本控制。

🛡️ 代码质量与安全:你的私人“代码审查员”

  • /review:启动代码审查,Claude会从代码质量、最佳实践等角度提出建议。
  • /security-review:一个内置的安全扫描器,检查SQL注入、XSS等常见漏洞。可以在提交代码前运行,也可集成到CI/CD中。
  • /simplify:并行启动三个审查Agent,分别检查代码的复用性、质量和效率,然后汇总结果并自动修复,帮你把代码改得更简洁。
  • /debug:启动调试模式,当遇到棘手Bug时,Claude会调用相关工具,更有条理地分析问题。

💬 会话与效率管理:让协作更丝滑

  • /compact vs /clear:随着对话变长,上下文窗口会越来越满。/compact会智能压缩对话历史,只保留核心摘要,适合继续当前任务时释放空间;/clear则会清空所有历史记录,适合开启一个完全不相关的新任务。
  • /rename & /resume:给会话起一个有意义的名字(如 /rename fix-auth-bug),之后可以通过 /resume fix-auth-bug 精准恢复该会话。
  • /export:将整个对话(包括Prompt、回复、工具调用)导出为Markdown文件,方便撰写文档、分享排查过程或自己复盘。
  • /rewind:对话和代码的“时光机”。尝试新思路搞砸了,直接回退到之前的某个检查点,比手动Ctrl+Z高效得多。
  • /btw:在主任务中插入一个“侧边聊天”,临时问个不相关问题而不打断主流程。

🤖 自动化与高级玩法

  • /plan (规划模式):进行复杂任务前,先让Claude输出操作方案供你确认,确认无误后再执行,避免改错方向。
  • /agents:创建拥有独立上下文的子Agent,并行处理特定任务(如“分析这个日志”),然后将结果汇总。这适合处理复杂的多步骤任务。
  • /batch:面向大规模代码改造,自动将任务拆解成多个独立单元,由后台Agent在隔离的Git worktree中并行执行,完成后分别发起PR。
  • /loop:定时重复执行任务,比如每隔几分钟检查一次部署状态或轮询某个接口。
  • /model:动态切换底层模型,简单任务切小模型省Token,复杂任务换回最强模型。

其他效率提升技巧:把工具用到极致

除了斜杠命令,这些技巧能让你在操作层面更加游刃有余。

一、键盘快捷键:指间的“魔法”

快捷键功能说明使用场景
! 前缀直接执行Bash命令,无需Claude思考。想快速查看状态:!git status。省Token又省时间。
双击 Esc时光倒流:回退到上一个干净检查点,代码和对话一起恢复。尝试方案走偏时,快速回到起点。
Ctrl + R搜索你输入过的历史Prompt。想复用之前写过的一个复杂提示词。
Ctrl + S暂存草稿:把当前未写完的Prompt暂存起来,清空输入框。写到一半需要临时处理其他事,避免思路断掉。
Shift + Tab在“自动执行”和“规划模式”等权限模式间快速切换。想让Claude直接改代码,还是先只做规划。
/vim开启Vim键位绑定,用hjkl导航,用dd删除行。如果你是个Vim爱好者,可以完全脱离鼠标。

二、上下文管理的艺术

  • 善用 @ 提及:像在Slack里一样,使用 @文件名@目录名 直接将特定文件或整个目录拉入上下文,避免让Claude在整个代码库里大海捞针。
  • > ultrathink 触发深度思考:在复杂问题前加上 ultrathink 关键字,Claude会分配更多Token进行深度推理,适合架构设计和复杂链路排查。
  • 定期清理会话:切换到不相关任务前,务必使用 /clear 命令,避免上下文污染。
  • 不要解释错误,直接贴原始数据:报错、日志、CI输出,越原始越有用。先自己总结一遍,往往会丢掉能定位根因的细节。正确的做法是 cat error.log | claude "解释这个错误"

三、让Claude进入“执行闭环”

  • 给Claude明确的反馈循环:不要只说“帮我改一下”,要把验证动作一起写进去。例如:“修复登录问题。改完后执行 pnpm lintpnpm test,如果失败,继续修到通过。”
  • 先跑 /init,再开工:这能确保Claude对你的项目有基本认知,后续的每一次交互都会更准确。
  • UI改动要真实验证:对于前端页面改动,尽量让Claude能看到真实效果,而不仅仅是“看代码”。
  • --worktree 控制并行任务污染:为不同任务创建独立的Git worktree,让每个Claude会话拥有独立的工作区和分支,互不影响。

四、将经验沉淀为规则

  • 善用 CLAUDE.md.claude/rules/CLAUDE.md 是给AI看的项目说明书,应放一些全局约束,如启动命令、编码约定、禁止触碰的边界等。而 .claude/rules/ 目录下可以放条件性规则,比如只有在处理 src/api/ 目录下的代码时才生效的规则。每一条规则都问一句:“没有它,Claude会做错吗?”,如果不会,那它可能就是冗余的。
  • 犯过的错要沉淀:每当Claude犯了错,比如“改接口忘补测试”或“动了不该动的Migration”,不要只修正这一次。最好通过 /memory 或自然语言说“把这条更新到CLAUDE.md里”,把它变成未来不会再犯的规则。
  • 创建自定义命令和Skill:如果你发现某个Prompt反复使用,可以把它制作成自定义的斜杠命令。方法很简单:在 .claude/commands/ 目录下新建一个 .md 文件,文件名就是命令名,内容就是Prompt。团队还可以将这些命令提交到Git,实现知识共享。

上下文压缩

Claude Code 的压缩对话,本质上是一种智能的“上下文摘要与状态恢复”机制。它用一个结构化的摘要替换掉冗长的历史对话,从而释放宝贵的上下文窗口空间,让对话可以继续进行。

这个机制的核心是“有选择地遗忘”,而不是简单地删除历史记录。

🧠 核心流程:压缩如何发生?

当对话上下文达到容量上限时(通常是手动执行/compact命令或自动触发),压缩流程开始。这个过程可以分为几个关键步骤:

  1. 深度摘要 (Deep Summarization):系统会启动一个“压缩专用”的Claude实例,它的任务是将整个对话历史提炼成一份结构化的摘要。为了保证摘要质量,这个实例被强制禁止使用任何工具,只能输出纯文本。
    • 摘要结构:生成的摘要非常严格,必须包含主要请求、技术概念、文件和代码段、错误与修复、待办任务等关键部分,确保不丢失项目核心信息。
  2. 注入边界标记 (Compacted Boundary):摘要生成后,会在对话记录中插入一个系统标记 ✻ Conversation compacted。这个标记是一个“检查点”,告诉系统此前的详细历史已被折叠。

  3. 重组上下文 (Context Rebuilding):为了让AI能无缝衔接工作,系统会用新组成的上下文替换旧的历史。新上下文包含三部分:
    • 边界标记:作为压缩点的标志。
    • 生成的摘要:作为新对话的“记忆”起点。
    • 关键状态恢复:这是很关键的一步。系统会主动恢复最近最多5个文件的内容(每个文件最多5000 tokens),以及之前加载的Skills和计划任务状态,确保AI不会“失忆”。

💾 本地存储与传输变化

这个机制对本地和网络有不同的影响:

  • 本地记录(JSONL文件):历史记录不会被删除。压缩只是追加了一条compact_boundary记录和摘要消息,所以本地文件大小反而会增大。如果想要追溯完整历史,可以查看这些文件。
  • API传输(请求体):这是压缩的主要战场。之前动辄上万token的请求体,在压缩后会缩减约85%~87%,后续请求主要只发送摘要和最新消息,大大降低了token消耗和费用。

🤔 自动 vs. 手动,以及注意事项

  • 自动触发:当上下文使用量达到模型窗口的95%左右(约16.7万 tokens)时,Claude Code会自动进行压缩。这个机制是静默运行的,你可能会在对话中突然看到✻ Conversation compacted的标记。
  • 手动触发:通过输入/compact命令可以随时手动发起。建议在完成一个阶段性的任务后手动压缩,可以避免AI在长对话中“跑偏”。

重要提示:由于压缩是自动、静默发生的,项目中一些非全局的指令可能会在此过程中“丢失”。例如,通过 paths: 定义的特定路径下的规则,或位于子目录中的CLAUDE.md文件,在压缩后可能不会自动重新加载。如果你发现AI突然“忘记”了某些规则,很可能就是自动压缩导致的。最稳妥的做法是,将那些在任何情况下都不应丢失的核心指令,放在项目根目录的CLAUDE.md文件中

【AI】LLM Powered Autonomous Agents

【AI】LLM Powered Autonomous Agents

本文转载自OpenAI 的应用研究主管 Lilian Weng 的github博客文章

OpenAI 的应用研究主管 Lilian Weng 撰写了一篇博客,认为 AI Agent 可能会成为新时代的开端。她提出了 Agent=LLM + 规划技能 + 记忆 + 工具使用的基础架构,其中 LLM 扮演了 Agent 的 “大脑”,在这个系统中提供推理、规划等能力。

结构图:


1. 智能体系统概述 (Agent System Overview)

构建以 LLM(大语言模型)为核心控制器的智能体是一个非常酷的概念。诸如 AutoGPT, GPT-EngineerBabyAGI 等概念验证演示(Proof-of-concepts demos)都是极具启发性的例子。LLM 的潜力远不止于生成优美的文案、故事、散文或程序;它可以被构建成一个强大的通用问题解决器。

在一个 LLM 驱动的自主智能体系统中,LLM 充当智能体的“大脑”,并辅以以下几个关键组件:

  • 规划 (Planning)
    • 子目标与分解 (Subgoal and decomposition): 智能体将大型任务分解为更小、更易管理的子目标,从而高效处理复杂任务。
    • 反思与精炼 (Reflection and refinement): 智能体能够对过去的行为进行自我批评和自我反思,从错误中学习,并为未来的步骤进行优化,从而提高最终结果的质量。
  • 记忆 (Memory)
    • 短期记忆 (Short-term memory): 我认为所有的“上下文学习 (In-context learning)”都属于利用模型的短期记忆。
    • 长期记忆 (Long-term memory): 这使智能体具备保留和回忆(无限)信息的能力,通常通过利用外部向量存储(Vector Store)和快速检索来实现。
  • 工具使用 (Tool use)
    • 智能体学会调用外部 API 来获取模型权重中缺失的信息(通常在预训练后很难改变),包括当前信息、代码执行能力、访问专有信息源等。

2. 组件一:规划 (Planning)

复杂任务通常涉及许多步骤。智能体需要知道它们是什么,并提前规划。

2.1 任务分解 (Task Decomposition)

思维链 (Chain of Thought, CoT) 已成为一种标准的提示技术,用于提升模型在复杂任务上的表现。模型被指示“逐步思考”,利用更多的测试时计算资源将困难任务分解为更小、更简单的步骤。CoT 将大任务转化为多个可管理的任务,并揭示了模型的思维过程。

思维树 (Tree of Thoughts, ToT) 通过在每个步骤中探索多种推理可能性来扩展 CoT。它首先将问题分解为多个思维步骤,并为每一步生成多个思维,从而创建树状结构。搜索过程可以是 BFS(广度优先搜索)或 DFS(深度优先搜索),每个状态通过分类器(通过提示)或多数投票进行评估。

任务分解可以通过以下方式完成:

  1. 通过简单的提示让 LLM 自行完成,例如 "XYZ 的步骤。\n1.", "实现 XYZ 的子目标是什么?"
  2. 使用特定任务的指令;例如,写小说时用 "写一个故事大纲"
  3. 结合人类输入。

另一种截然不同的方法是 LLM+P,它依赖于外部的经典规划器(Classical Planner)来进行长视野规划。此方法利用 PDDL (Planning Domain Definition Language) 作为中间接口来描述规划问题。在此过程中,LLM:

  1. 将问题翻译为“Problem PDDL”。
  2. 请求经典规划器基于现有的“Domain PDDL”生成 PDDL 计划。
  3. 将 PDDL 计划翻译回自然语言。 本质上,规划步骤被外包给了外部工具,这在某些机器人设置中很常见,但在许多其他领域并非如此。

2.2 自我反思 (Self-Reflection)

自我反思是智能体通过精炼过去的行为决策并纠正先前的错误来迭代改进自身的关键方面。在不可避免需要试错的现实世界任务中,这一点至关重要。

  • ReAct (Reasoning + Acting) ReAct 通过将动作空间扩展为“特定任务的离散动作”和“语言空间”的组合,在 LLM 中集成了推理和行动。前者使 LLM 能够与环境交互(例如使用 Wikipedia 搜索 API),而后者促使 LLM 生成自然语言形式的推理轨迹。 ReAct 的提示模板包含 LLM 的明确思考步骤,格式大致如下:
    思考 (Thought): ...
    行动 (Action): ...
    观察 (Observation): ...
    ... (重复多次)
    

    在知识密集型任务和决策任务的实验中,ReAct 的表现都优于移除了 思考 步骤的 Act-only 基线。

  • Reflexion (反射) Reflexion 是一个为智能体配备动态记忆和自我反思能力以提高推理技能的框架。Reflexion 具有标准的 RL(强化学习)设置,其中奖励模型提供简单的二元奖励,动作空间遵循 ReAct 的设置(用语言增强特定任务的动作空间)。在每次动作 $a_t$ 后,智能体计算一个启发式函数 $h_t$,并根据自我反思结果决定是否重置环境以开始新一轮尝试。
    • 启发式函数:确定轨迹何时效率低下或包含幻觉(Hallucination)并应停止。效率低下指花费太长时间未成功。幻觉指在环境中遇到导致相同观察结果的一连串相同动作。
    • 自我反思:通过向 LLM 展示两个失败轨迹与理想反思的示例对来创建。然后将反思添加到智能体的工作记忆中(最多三个),作为查询 LLM 的上下文。
  • 事后诸葛亮思维链 (Chain of Hindsight, CoH) CoH 通过明确展示给模型一系列经过注释的过去输出(包含反馈),鼓励模型基于自身输出进行改进。人类反馈数据是 $D_h = {(x, y_i, r_i, z_i)}{i=1}^n$ 的集合,其中 $x$ 是提示,$y_i$ 是模型补全,$r_i$ 是 $y_i$ 的人类评分,$z_i$ 是相应的人类提供的事后诸葛亮反馈。 假设反馈元组按奖励排序 ($r_n \ge r{n-1} \ge \dots \ge r_1$)。该过程是监督微调,数据是一个序列形式 $\tau_h = (x, z_i, y_i, z_j, y_j, \dots, z_n, y_n)$。模型微调的目标是仅预测 $y_n$(基于序列前缀),使模型能够基于反馈序列进行自我反思以产生更好的输出。可选地,模型在测试时可以接收多轮带有人类注释者的指令。
    • 为防止过拟合,CoH 添加了一个正则化项以最大化预训练数据集的对数似然。
    • 为防止走捷径和复制(因为反馈序列中有许多常见词),在训练期间随机屏蔽 0%-5% 的过去 token。
  • 算法蒸馏 (Algorithm Distillation, AD) AD 将 CoH 的思想应用到了强化学习任务的跨回合轨迹中,其中“算法”被封装在长历史条件策略中。假设智能体与环境交互多次,且在每个回合中智能体变得越来越好,AD 将这种学习历史连接起来并输入模型。因此,预期下一个预测动作将导致比前几次更好的性能。目标是学习 RL 的过程,而不是训练特定任务的策略本身。

3. 组件二:记忆 (Memory)

3.1 记忆的类型 (Types of Memory)

记忆可以定义为用于获取、存储、保留和随后检索信息的过程。人脑中有几种类型的记忆:

  1. 感觉记忆 (Sensory Memory): 最早的记忆阶段,提供在原始刺激结束后的感官信息(视觉、听觉等)印象保留能力,通常仅持续几秒钟。
  2. 短期记忆 (Short-Term Memory, STM) 或 工作记忆 (Working Memory): 存储我们当前意识到并需要执行复杂认知任务(如学习和推理)的信息。短期记忆容量有限(约 7 个组块),持续 20-30 秒。
  3. 长期记忆 (Long-Term Memory, LTM): 可以将信息存储很长时间(几天到几十年),容量基本无限。分为:
    • 外显/陈述性记忆 (Explicit/Declarative): 事实和事件的记忆(如情景记忆、语义记忆)。
    • 内隐/程序性记忆 (Implicit/Procedural): 无意识的记忆,如骑自行车或打字。

在 AI 智能体中的映射:

  • 感觉记忆: 学习原始输入(文本、图像等)的嵌入表示。
  • 短期记忆: 上下文学习 (In-context learning)。受限于 Transformer 的有限上下文窗口,它是短暂的。
  • 长期记忆: 外部向量存储(Vector Store),智能体在查询时可通过快速检索访问。

3.2 最大内积搜索 (Maximum Inner Product Search, MIPS)

外部记忆可以缓解有限注意力跨度的限制。标准做法是将信息的嵌入表示保存到支持快速 MIPS 的向量存储数据库中。为了优化检索速度,通常选择 近似最近邻 (ANN) 算法来以牺牲一点精度换取巨大的速度提升。

常见的 ANN 算法:

  • LSH (Locality-Sensitive Hashing): 局部敏感哈希,将相似项目映射到相同桶中。
  • ANNOY (Approximate Nearest Neighbors Oh Yeah): 使用随机投影树(Random Projection Trees)。
  • HNSW (Hierarchical Navigable Small World): 受小世界网络启发,构建层级化的小世界图。
  • FAISS (Facebook AI Similarity Search): 假设高维空间中距离服从高斯分布,应用向量量化。
  • ScaNN (Scalable Nearest Neighbors): 使用各向异性向量量化。

4. 组件三:工具使用 (Tool Use)

工具使用是人类的显著特征。为 LLM 配备外部工具可以显著扩展模型能力。

  • MRKL 系统 MRKL(Modular Reasoning, Knowledge and Language)是一种神经符号(Neuro-symbolic)架构。它包含一组“专家”模块,通用 LLM 充当路由器,将查询路由到最合适的专家模块。这些模块可以是神经网络(深度学习模型)或符号型(计算器、货币转换器、天气 API)。
    • 实验表明,让 LLM 学会调用计算器解决口头数学问题比解决明确陈述的数学问题更难,因为 LLM 往往无法可靠地提取基本算术的正确参数。这突显了“何时以及如何使用工具”比工具本身更重要。
  • TALM 与 Toolformer 两者都通过微调语言模型(LM)来学习使用外部工具 API。

  • HuggingGPT 一个使用 ChatGPT 作为任务规划器的框架,根据模型描述选择 HuggingFace 平台上的模型,并根据执行结果总结响应。系统包含四个阶段:
    1. 任务规划 (Task Planning): LLM 将用户请求解析为多个任务(含任务类型、ID、依赖关系、参数)。
    2. 模型选择 (Model Selection): LLM 从候选列表中选择合适的专家模型。
    3. 任务执行 (Task Execution): 专家模型执行特定任务。
    4. 响应生成 (Response Generation): LLM 接收结果并提供给用户。
  • API-Bank 一个用于评估工具增强 LLM 性能的基准测试。它包含 53 个常用 API 工具、一个完整的工具增强 LLM 工作流,以及 264 个包含 568 次 API 调用的注释对话。它评估智能体在三个层面的能力:
    1. 调用 API (Call): 给定描述,确定是否调用给定 API。
    2. 检索 API (Retrieve): 搜索可能的 API 并通过阅读文档学习如何使用。
    3. 规划 API (Plan): 针对模糊请求(如安排行程),进行多 API 调用规划。

5. 案例研究 (Case Studies)

5.1 科学发现智能体 (Scientific Discovery Agent)

  • ChemCrow: 一个特定领域的例子,LLM 增强了 13 个专家设计的工具,用于完成有机合成、药物发现和材料设计任务。工作流结合了 ReActMRKL,指令模型遵循 思考、行动、行动输入、观察 的格式。
    • 有趣发现: 虽然 LLM 评估认为 GPT-4 和 ChemCrow 表现相当,但人类专家评估显示 ChemCrow 在化学正确性上远超 GPT-4。这表明使用 LLM 评估其在需要深厚专业知识领域的表现可能存在缺陷。
  • Boiko et al. (2023): 探索了用于科学发现的智能体,能够使用工具浏览互联网、阅读文档、执行代码、调用机器人实验 API。但也讨论了风险(如合成违禁药物),测试中 36% 的化学武器合成请求被接受。

5.2 生成式智能体模拟 (Generative Agents Simulation)

  • Generative Agents (Park et al. 2023): 一个有趣的实验,25 个由 LLM 驱动的虚拟角色生活在一个沙盒环境中(灵感来自《模拟人生》)。
    • 记忆流 (Memory Stream): 长期记忆模块,记录代理经历。
    • 检索 (Retrieval): 根据相关性、新颖性 (Recency)重要性 (Importance)(询问 LM 区分琐事和核心记忆)来检索上下文。
    • 反思 (Reflection): 生成关于过去事件的更高层次摘要(例如:“我昨晚睡得很好” -> “我现在感觉精力充沛”)。
    • 结果: 模拟产生了涌现的社会行为,如信息传播、关系记忆和社交活动协调。

5.3 概念验证示例 (Proof-of-Concept Examples)

  • AutoGPT: 引起了广泛关注,它将 LLM 作为主要控制器。虽然存在可靠性问题(由于自然语言接口),但这是一个很酷的概念验证。AutoGPT 的大量代码都用于格式解析。
  • GPT-Engineer: 根据自然语言规范创建整个代码仓库。它首先会列出需要澄清的超级短子弹列表,然后选择一个澄清问题等待用户回答,最后进入代码编写模式。

6. 挑战 (Challenges)

在构建以 LLM 为中心的智能体时,存在一些常见的局限性:

  1. 有限的上下文长度 (Finite context length): 限制了历史信息、详细指令、API 调用上下文和响应的包含。虽然向量存储可以提供更大的知识库,但其表示能力不如全注意力机制强大。
  2. 长期规划与任务分解的挑战 (Challenges in long-term planning): 在冗长的历史记录上进行规划和有效探索解决方案空间仍然很困难。当面临意外错误时,LLM 难以调整计划。
  3. 自然语言接口的可靠性 (Reliability of natural language interface): 当前的智能体系统依赖自然语言作为 LLM 与外部组件(记忆、工具)之间的接口。然而,模型输出的可靠性值得怀疑,LLM 可能会出现格式错误或偶尔表现出叛逆行为(拒绝遵循指令)。因此,许多智能体演示代码都集中在解析模型输出上。

【AI】ClaudeCode初体验与背景知识补齐

【AI】ClaudeCode初体验与背景知识补齐

本文旨在介绍初次使用ClaudeCode的记录,还有一些基础规则

在AI辅助编程的讨论中,人们往往过分迷恋底层大模型(LLM)的参数量和基准测试得分(Benchmark)。但我认为:Agent(智能体)的工程设计,其重要性与模型本身完全平起平坐,甚至决定了AI能否真正进入生产力核心。

一个再聪明的模型,如果没有良好的工程外壳(工具链调用、上下文控制、状态管理、报错自动追溯),它也只是一个“高谈阔论却无法干活”的面试者;而卓越的Agent工程,能让模型化身为真正能够自主闭环的“资深工程师”。

Claude Code 的定位非常不同——它不是一个简单的“代码补全弹窗”,而是一个直接运行在终端(Terminal)、具备 Agent(智能体)能力的 CLI 工具。它拥有文件读写、执行终端命令、运行测试甚至自主修复 Bug 的权限。

第一次使用记录

回顾自己作为开发者的这几年,我所经历的AI辅助编码路线,其实也是整个行业Agent技术演进的缩影。这段历史可以大概划分为三个阶段:

  • 阶段一:网页问答时代(CV工程师) ,浏览器多标签页(如早期 ChatGPT, Claude 网页版)。遇到需求或 Bug 时,在网页中用自然语言描述,等待 AI 生成代码片段。随后手动CV粘贴到本地 IDE 中,再根据报错进行微调。这种方式有比较大的局限性,即上下文极其断层。AI 对整个项目的架构设计、依赖库版本一无所知,开发者需要像“人体搬运工”一样不断喂报错信息,效率极低。
  • 阶段二:IDE 插件时代(TAB工程师) ,这时候涌现了一批在 Android Studio / IntelliJ IDEA 中的集成插件(如 GitHub Copilot, GitCode Marscode 等)。我们可以在熟悉的编辑环境中,AI 通过静态分析获取当前文件或相邻文件的部分上下文,提供行级代码补全,或者在侧边栏对选中代码进行解释和重构。这个阶段依然处于“被动响应”状态。它能帮你写一个小函数、改一个局部 Bug,但无法跨越多个模块独立完成一个大特性,更没有自主运行测试、根据编译报错自我修正的能力。
  • 阶段三:AI-Native IDE 与 Agent 时代(Yes/Accept工程师) ,以 Cursor 为代表的 AI 优先 IDE,内部引入了真正的 Agent 工程(如 Composer 模式)。它将一个复杂的研发任务拆解为不同性质的子任务(如:架构设计、代码编写、QA 测试),交由不同的专业 Agent 去协作完成。另外还有 Claude Code 这种程序员更偏爱的命令行硬核美学,它没有图形界面,直接驻留于你的终端(Shell)。同样基于多 Agent 协同体系。CC可以深度融入工作流,直接读取整个代码库,自主执行诸如 git statusgradlew assembleDebugpytest 等终端命令。它修改了代码后,会自动运行编译和测试,如果发现报错,Agent 会自主捕获终端的 Error 信息并进行下一轮的自我修正,直到任务完全验证通过。

社区真实评价与使用体验

自 Anthropic 推出 Claude Code 以来,整个开发者社区对其评价呈现出一种“硬核、务实、生产力爆棚”的基调。

相比于 Cursor 需要开发者转移到新的 IDE 阵营,玩转 Neovim、tmux 或习惯于全命令行操作的硬核开发者对 Claude Code 赞誉有加。它完美符合 Unix 哲学 —— “通过文本流与现有工具无缝组合”。

区非常推崇它的 /ultrareview(多轮深度代码审查)和自主执行循环。开发者只需丢下一句 “帮我把这个 Android 模块的底层网络库从 HttpURLConnection 重构为 OkHttp,并确保所有单元测试通过”,就可以去喝咖啡了,Claude Code 会在终端里自己和编译器“死磕”。

当然,也有不少开发者(如 Reddit 的 r/ClaudeCode 板块)指出,当面对极其庞大且缺乏规范的陈旧代码库时,Agent 偶尔也会陷入“打补丁式”的无效循环,导致 Token 消耗极快(Token Drift)。这进一步佐证了:开发者如何通过结构化文档(如编写 CLAUDE.md 规范)去引导 Agent 工程,是高效协作的关键。

在国内如何用 DeepSeek 丝滑驱动 Claude Code

由于众所周知的原因,在国内直接直连 Anthropic 官方服务存在网络门槛和账号限制。然而,得益于 DeepSeek 的强势崛起及其对 Anthropic 兼容 API 的完美原生支持,我们完全可以在国内无缝复用 Claude Code 的强大 Agent 外壳,将底层大模型替换为高性价比、低延迟的 DeepSeek V4。

为什么不使用哪些中转站呢? 相比于将自己的项目信息和工程安全暴露给不知名的某些组织,我更愿意相信大厂的服务,所以还是选择了最近新发布的Deepseek V4系列来作为ClaudeCode的大脑。

我看到DeepSeek 官方提供了针对 Anthropic 格式的专属兼容端点。我们只需要在本地终端通过环境变量对 Claude Code 进行重定向代理即可。

步骤一:配置环境变量

在你的终端配置文件(如 .bashrc.zshrc)中,注入以下环境变量(注意:不要设置 ANTHROPIC_API_KEY 以免触发官方鉴权冲突):

# 关键:指定 DeepSeek 的 Anthropic 兼容 Base URL
export ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic"

# 填入你的 DeepSeek API Key(通过 Auth Token 变量注入)
export ANTHROPIC_AUTH_TOKEN="sk-your-deepseek-api-key-here"
unset ANTHROPIC_API_KEY

# 指定 Claude Code 运行时的核心模型与辅助模型
export ANTHROPIC_MODEL="deepseek-v4-pro"
export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash"

# 可选:关闭非必要的官方启动流量,提升国内首屏加载速度
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1

步骤二:启动与绕过本地权限限制

在项目根目录下启动 Claude Code。由于使用了第三方模型代理,建议加上权限绕过参数以保证本地 Tool(如执行 Bash、读写文件)的顺畅:

claude --permission-mode bypassPermissions

💡 高阶贴士(开源代理工具): 如果在配置过程中遇到证书或复杂的 JSON 元数据兼容问题,国内开发者社区目前非常流行使用开源的 claude-tap 或者是桌面端代理工具 CCPG (Claude Code Proxy Gateway)。它们能够作为本地中间件,自动抹平 DeepSeek 与 Claude Code 之间微小的 API 字段差异,实现真正的“零漏接”丝滑开发。

Claude Code 的使用注意事项

权限过高也不一定是好事,OpenClaw之前爆出的众多安全问题也印证了这一点。我们如何在开发中高效且安全地使用它,我梳理了以下背景知识与核心注意事项:

1. 它是基于 CLI 的 Agent,而非单纯的 Chat

Claude Code 不是网页端聊天窗口。你把它叫出来后,它会“潜入”你的项目目录。它不仅能看代码,还能自己执行 git statusgradlew testpytestmake

2. CLAUDE.md — 给AI看的“项目说明书”

这是 Claude Code 官方极其推崇的机制。在项目根目录下创建一个 CLAUDE.md 文件,里面用来存放:

  • 项目技术栈与构建/测试命令(例如:如何运行单测、如何编译)。
  • 代码风格与架构规范(例如:在 Android 中必须使用 Kotlin 协程,禁止使用 RxJava;或者 Python 项目必须符合 PEP8)。
  • 在项目经历了比较大的重构,比如软件架构,依赖库大升级时,最好及时更新一下这个CLAUDE.md文档,以让cc每次都可以掌握最新最准确的项目信息。

💡 注意:这个文件要精简。如果写得太长像个老太婆的裹脚布,Claude 反而会漏掉关键信息。

3. 三种核心工作模式

Claude Code 支持通过快捷键(如 Shift + Tab)或命令切换模式:

  • Ask before edits(默认/问答模式):它在修改任何文件前都会问你“我可以改吗?”,适合日常边写边聊。
  • Auto Mode(自动/自主模式):你给它一个复杂任务(如“帮我把这个模块的各种边界条件单测补齐”),它会自动连续执行“修改-运行测试-报错-再修改”的闭环,直到成功。
  • Plan Mode(计划模式):遇到大需求时,先让它出一份架构和执行计划书,你审批过了再敲定执行。

4. 严格管理 Context(上下文)污染 —— 善用 /clear

  • 痛点:网页端聊天习惯了“一个窗口聊到底”,但在 Claude Code 里,对话越长,Token 消耗越恐怖(钱包在流血),且 AI 越容易糊涂
  • 避坑:不要搞“大杂烩会话”。刚让它修完一个网络请求的 Bug,接下来想让它写一个 UI 布局,请果断输入 /clear 清空当前上下文,重新开始。
  • 二次修正定律:如果 Claude 连续两次都改错了,不要继续纠正它。此时上下文已经充满了错误代码的干扰。正确做法是 /clear,然后重新组织你的 Prompt,把正确的限制条件一次性喂给它。

5. 权限控制:警惕危险的“自动模式”

  • 痛点:在自动模式下,Claude 会自己执行 Shell 命令。如果你的项目中包含敏感脚本,或者它理解错了意图,可能会误删文件或执行了死循环。
  • 避坑:在涉及 rm、环境变动或操作未提交的 Git 代码时,仔细审查它的计划。建议在干净的 Git 分支(Clean working directory)上使用它,随时准备 git reset --hard

6. 信任但必须验证(Trust, but Verify)

  • Claude 经常能写出看似无懈可击但实际上包含隐藏 Bug(如 Android 中的内存泄漏、Python 中的并发竞态问题)的代码。
  • 避坑:不要盲目 Trust。给 Claude 一种能够自我验证的方式。例如:“请帮我重构这个类,并且运行项目中的测试确保没有打破现有逻辑。” 无法通过自动化测试或编译验证的代码,绝不要直接上线。

7. 任务原子化(Atomization)

  • 错误示范:“帮我重构整个 Android 客户端的登录注册流程。”(任务太大,AI 容易在中间迷失,改出无数编译错误)。
  • 正确示范
    1. “先帮我检查现在的登录数据校验逻辑(Data Validation),找出漏洞。”
    2. “在这个 Foundation 层增加一个密码加密的工具类。”
    3. “最后去修改 ViewModel 里的调用逻辑。”

【AI】基于 Qwen2.5 模型进行 LoRA 微调

【AI】基于 Qwen2.5 模型进行 LoRA 微调

本文介绍了Lora训练的相关知识和实操记录

前言:什么是 LoRA?为什么选它?

LoRA(Low-Rank Adaptation) 是一种参数高效微调(PEFT)技术。想象你有一个预训练好的大模型(如 Qwen2.5),它已经在海量数据上学会了语言规律。现在你想让它学会特定任务(如数学题推理),但有两个问题:

  1. 算力问题:全参数微调需要巨大的 GPU 内存和训练时间
  2. 存储问题:每次微调都保存一个完整模型(3GB+),成本高昂

LoRA 的解决方案:冻结原模型参数,只训练少量”低秩矩阵”(Adapter)。这就像给手机装插件,而不是重写整个操作系统。

本文目标:在 Apple Silicon(M1/M2/M3)Mac 上,使用 MLX 框架对 Qwen2.5-1.5B 进行 LoRA 微调,让模型学会”分步思考”的推理能力。


第一章:环境准备与工具链搭建

1.1 为什么选择 MLX?

MLX 是 Apple 专为自家芯片设计的机器学习框架,相比 PyTorch 有以下优势:

特性MLXPyTorch (MPS)
内存效率⭐⭐⭐ 极高,统一内存架构优化⭐⭐ 较好,但显存管理较粗
训练速度⭐⭐⭐ 针对 Metal 深度优化⭐⭐ 通用实现,非 Apple 最优
量化支持⭐⭐⭐ 原生支持 4-bit/8-bit⭐⭐ 需额外配置
生态成熟度⭐⭐ 较新,文档在完善中⭐⭐⭐ 成熟,社区庞大

决策建议:如果你主要在 Mac 上做训练/推理,MLX 是首选;如果需要跨平台部署,PyTorch 更通用。

1.2 安装 Miniconda(环境隔离)

# 下载 ARM64 版本(适配 Apple Silicon)
curl -O https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh

# 执行安装脚本
bash Miniconda3-latest-MacOSX-arm64.sh

# 安装完成后,重启终端或执行以下命令使配置生效
source ~/.zshrc  # 或 ~/.bashrc

⚠️ 注意事项

  • 安装过程中会询问是否初始化 conda,建议选 yes(自动配置 PATH)
  • 如果之前安装过 Anaconda,建议先完全卸载,避免环境冲突
  • 安装路径建议保持默认(~/miniconda3),避免权限问题

1.3 创建隔离的 Python 环境

# 创建 Python 3.11 环境(MLX 对 3.9-3.11 支持最好)
conda create -n mlx-train python=3.11 -y

# 激活环境
conda activate mlx-train

# 验证 Python 版本
python --version  # 应显示 Python 3.11.x

为什么用 3.11 而非最新版? MLX 和许多 ML 库对 Python 版本敏感,3.11 是目前稳定性与性能的最佳平衡点。

1.4 安装核心依赖包

# 核心:MLX 语言模型工具包
pip install mlx-lm

# 辅助工具(用于后续验证和格式转换)
pip install torch transformers accelerate safetensors

# 分词器依赖(Qwen 系列必需)
pip install sentencepiece protobuf tiktoken

# 数据处理
pip install datasets  # HuggingFace 数据集工具
pip install jsonlines  # 高效处理 JSONL 文件

依赖冲突排查

# 如果安装后出现问题,检查版本兼容性
pip list | grep -E "(mlx|torch|transformers)"

第二章:模型获取与本地部署

2.1 安装 HuggingFace CLI 工具

# 使用 Homebrew 安装(推荐)
brew install huggingface-cli

# 验证安装
hf --version

重要变更提醒:旧版命令 huggingface-cli 已简化为 hf,但部分文档可能仍使用旧命令,注意区分。

2.2 配置 HuggingFace 访问权限

# 登录(需要 HuggingFace 账号和 Access Token)
hf login

# 或者设置环境变量(自动化脚本推荐)
export HF_TOKEN="your_token_here"

获取 Token 步骤

  1. 访问 https://huggingface.co/settings/tokens
  2. 创建 New Token(选择 read 权限即可下载模型)
  3. 复制并妥善保存(Token 只显示一次)

2.3 下载 Qwen2.5 模型

# 创建模型存储目录
mkdir -p ./models

# 下载 1.5B 指令版(适合 Mac 本地训练)
hf download Qwen/Qwen2.5-1.5B-Instruct --local-dir ./models/Qwen2.5-1.5B-Instruct

模型选择指南

模型版本参数量磁盘占用内存需求适用场景
Qwen2.5-0.5B5亿~1GB4GB+快速测试、边缘设备
Qwen2.5-1.5B15亿~3GB8GB+Mac 本地训练推荐
Qwen2.5-7B70亿~14GB24GB+高性能 Mac/云端
Qwen2.5-14B140亿~28GB48GB+专业工作站

下载优化技巧

# 如果下载中断,使用 resume 继续
hf download Qwen/Qwen2.5-1.5B-Instruct --local-dir ./models --resume-download

# 仅下载特定文件(如只需要 safetensors)
hf download Qwen/Qwen2.5-1.5B-Instruct --include "*.safetensors" "*.json"

2.4 模型完整性验证

下载完成后,验证文件结构:

ls -lh ./models/Qwen2.5-1.5B-Instruct/
# 应包含:config.json, model.safetensors, tokenizer.json 等

第三章:模型推理能力基线测试

3.1 编写验证脚本

在正式训练前,必须测试基础模型是否能正常运行,这将成为后续对比的基线。

# verify_base_model.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import time

def test_model():
    # 配置路径(根据实际下载路径调整)
    model_path = "./models/Qwen2.5-1.5B-Instruct"
    
    print("🔄 正在加载分词器...")
    tokenizer = AutoTokenizer.from_pretrained(
        model_path, 
        trust_remote_code=True,
        padding_side="left"  # 生成任务建议左填充
    )
    print(f"✅ 分词器加载成功 | 词表大小: {len(tokenizer)}")
    
    print("\n🔄 正在加载模型(约 3GB,请耐心等待)...")
    start_time = time.time()
    
    model = AutoModelForCausalLM.from_pretrained(
        model_path,
        torch_dtype=torch.float16,      # 半精度节省内存
        device_map={"": "mps"},         # 强制使用 Mac GPU
        trust_remote_code=True,
        low_cpu_mem_usage=True          # 优化加载内存峰值
    )
    
    load_time = time.time() - start_time
    print(f"✅ 模型加载成功 | 耗时: {load_time:.1f}s | 设备: {model.device}")
    
    # 测试用例:观察基础模型的推理能力
    test_cases = [
        "你好,请介绍一下你自己。",
        "1+1等于几?请详细解释。",
        "有3只兔子,每只4条腿,一共有多少条腿?请分步思考。"
    ]
    
    print("\n" + "="*50)
    print("🧪 开始推理测试")
    print("="*50)
    
    for i, prompt in enumerate(test_cases, 1):
        print(f"\n--- 测试 {i} ---")
        print(f"输入: {prompt}")
        
        # 构建对话格式(Qwen  chat template)
        messages = [{"role": "user", "content": prompt}]
        text = tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True
        )
        
        inputs = tokenizer(text, return_tensors="pt").to("mps")
        
        # 生成参数调优
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=200,
                temperature=0.7,        # 控制创造性
                top_p=0.9,              # 核采样
                repetition_penalty=1.1,  # 抑制重复
                do_sample=True
            )
        
        response = tokenizer.decode(outputs[0], skip_special_tokens=True)
        # 提取助手回复部分
        if "assistant" in response:
            response = response.split("assistant")[-1].strip()
        
        print(f"输出: {response[:200]}...")
        print(f"生成长度: {len(outputs[0])} tokens")

if __name__ == "__main__":
    test_model()

3.2 关键参数解析

参数作用建议值
torch_dtype权重精度float16(Mac 推荐)或 bfloat16
device_map设备分配{"": "mps"} 强制 GPU,"auto" 自动分配
low_cpu_mem_usage分片加载True(大模型必需)
temperature采样随机性0.1-0.3(确定性任务),0.7-0.9(创造性)

⚠️ 常见问题

  • MPS 后端报错:如果遇到 MPS backend out of memory,尝试重启 Python 进程释放显存
  • 结果不一致:MPS 的确定性不如 CUDA,相同输入可能有细微差异,属正常现象

第四章:训练数据准备与格式化

4.1 理解数据格式要求

MLX LoRA 训练要求数据为 JSON Lines 格式(.jsonl),每行一个 JSON 对象,必须包含 text 字段:

{"text": "User: 问题\n\nAssistant: <|thought|>\n思考过程\n<|solution|>\n最终答案"}

4.2 原始数据结构分析

你的原始数据包含以下关键字段:

  • problem: 问题描述
  • thinking: 详细思考过程(CoT - Chain of Thought)
  • solution: 结构化答案

训练目标:让模型学会在看到问题时,先输出 <|thought|> 标记,然后展示思考过程,最后给出答案。

4.3 数据转换脚本(增强版)

# convert_dataset.py
import json
import random
import argparse
from pathlib import Path
from typing import List, Dict

def validate_data(data: Dict) -> bool:
    """验证数据完整性"""
    required_fields = ["problem", "thinking", "solution"]
    return all(field in data and data[field] for field in required_fields)

def format_prompt(problem: str, thinking: str, solution: str, 
                  template_type: str = "default") -> str:
    """
    支持多种提示词模板
    """
    templates = {
        "default": (
            f"User: {problem}\n\n"
            f"Assistant: <|thought|>\n{thinking}\n"
            f"<|solution|>\n{solution}"
        ),
        "chatml": (
            f"<|im_start|>user\n{problem}<|im_end|>\n"
            f"<|im_start|>assistant\n<|thought|>\n{thinking}\n"
            f"<|solution|>\n{solution}<|im_end|>"
        ),
        "raw": f"{problem}\n\n思考:{thinking}\n\n答案:{solution}"
    }
    return templates.get(template_type, templates["default"])

def split_dataset(data: List[Dict], train_ratio: float = 0.9, 
                  seed: int = 42) -> tuple:
    """划分训练集和验证集"""
    random.seed(seed)
    random.shuffle(data)
    split_idx = int(len(data) * train_ratio)
    return data[:split_idx], data[split_idx:]

def convert_dataset(input_path: str, output_dir: str, 
                    template_type: str = "default",
                    train_ratio: float = 0.9):
    """
    主转换函数
    """
    input_file = Path(input_path)
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)
    
    # 读取原始数据
    raw_data = []
    with open(input_file, 'r', encoding='utf-8') as f:
        for line_num, line in enumerate(f, 1):
            try:
                data = json.loads(line.strip())
                if validate_data(data):
                    raw_data.append(data)
                else:
                    print(f"⚠️  跳过第 {line_num} 行:字段缺失")
            except json.JSONDecodeError:
                print(f"❌ 解析错误第 {line_num} 行")
    
    print(f"📊 有效数据: {len(raw_data)} 条")
    
    # 划分数据集
    train_data, valid_data = split_dataset(raw_data, train_ratio)
    print(f"📦 训练集: {len(train_data)} 条 | 验证集: {len(valid_data)} 条")
    
    # 转换并保存
    def save_split(data: List[Dict], filename: str):
        output_file = output_path / filename
        with open(output_file, 'w', encoding='utf-8') as f:
            for item in data:
                formatted = format_prompt(
                    item["problem"], 
                    item["thinking"], 
                    item["solution"],
                    template_type
                )
                # 统计长度(用于后续分析)
                token_len = len(formatted) // 4  # 粗略估算
                record = {
                    "text": formatted,
                    "metadata": {
                        "original_id": item.get("id", ""),
                        "token_estimate": token_len
                    }
                }
                json.dump(record, f, ensure_ascii=False)
                f.write('\n')
        print(f"✅ 已保存: {output_file}")
    
    save_split(train_data, "train.jsonl")
    if valid_data:
        save_split(valid_data, "valid.jsonl")
    
    # 生成数据报告
    lengths = [len(item["text"]) for item in raw_data]
    print(f"\n📈 数据统计:")
    print(f"   平均长度: {sum(lengths)/len(lengths):.0f} 字符")
    print(f"   最大长度: {max(lengths)} 字符")
    print(f"   最小长度: {min(lengths)} 字符")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", required=True, help="输入 JSONL 文件")
    parser.add_argument("--output", default="./data", help="输出目录")
    parser.add_argument("--template", default="default", 
                       choices=["default", "chatml", "raw"])
    parser.add_argument("--train-ratio", type=float, default=0.9)
    
    args = parser.parse_args()
    convert_dataset(args.input, args.output, args.template, args.train_ratio)

运行命令

python convert_dataset.py \
    --input ./raw_data/dataset.jsonl \
    --output ./data \
    --template default \
    --train-ratio 0.95  # 留 5% 做验证

4.4 数据质量检查清单

执行转换后,务必检查:

# 1. 检查文件格式
head -n 1 ./data/train.jsonl | python -m json.tool

# 2. 统计行数(应与原始数据匹配)
wc -l ./data/train.jsonl ./data/valid.jsonl

# 3. 检查文本长度分布(避免过长数据)
python -c "
import json
lengths = []
with open('./data/train.jsonl') as f:
    for line in f:
        data = json.loads(line)
        lengths.append(len(data['text']))
print(f'Max: {max(lengths)}, Min: {min(lengths)}, Avg: {sum(lengths)/len(lengths):.0f}')
"

⚠️ 关键警告

  • 序列长度:MLX 默认 max_seq_length=2048,超过会被截断
  • 截断风险:如果截断发生在答案部分,模型将学不到完整输出
  • 建议:预处理阶段过滤掉 >3000 字符的样本,或手动截断到合理长度

第五章:LoRA 训练实战

5.1 训练参数详解

python -m mlx_lm.lora \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --train \
    --data ./data \
    --iters 1000 \
    --batch-size 1 \
    --steps-per-report 10 \
    --adapter-path ./output/qwen_reasoning_adapter \
    --learning-rate 1e-5 \
    --lora-rank 8 \
    --lora-alpha 16 \
    --lora-dropout 0.05 \
    --max-seq-length 2048 \
    --save-every 100

参数深度解析

参数含义调参建议
--lora-rank低秩矩阵维度8-64,越大表达能力越强,但易过拟合
--lora-alpha缩放系数通常设为 rank 的 2 倍
--learning-rate学习率1e-5 到 5e-5,过大导致不稳定
--batch-size批次大小Mac 上保持 1,显存充足可增大
--max-seq-length最大序列长度根据数据长度调整,需留余量

5.2 训练过程监控

正常训练日志解读

Iter 10: Train loss 1.435, Learning Rate 1.000e-05, It/sec 0.326...

关键指标

  • Train loss:应持续下降,最终稳定在 0.5-1.5 之间
  • It/sec:迭代速度,Mac M3 约 0.3-0.5 it/s
  • Peak mem:内存峰值,超过物理内存会触发 SWAP 导致极慢

危险信号

  • loss = nan:学习率过大或梯度爆炸,立即停训
  • loss 不降反升:学习率过高或数据有问题
  • loss 降到 0.01 以下:严重过拟合,模型变成复读机

5.3 内存优化策略

如果训练时内存不足:

# 方案 1:开启梯度检查点(牺牲速度换内存)
--gradient-checkpointing

# 方案 2:使用量化模型作为基础
--model Qwen/Qwen2.5-1.5B-Instruct-4bit

# 方案 3:减小序列长度(确保覆盖 90% 数据即可)
--max-seq-length 1024

第六章:模型评估与推理测试

6.1 使用 Adapter 进行推理

python -m mlx_lm.generate \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --adapter-path ./output/qwen_reasoning_adapter \
    --prompt "User: 一个水池有两个进水管,A管单独注满需6小时,B管单独注满需4小时,同时打开两管需几小时注满?\n\nAssistant: <|thought|>" \
    --max-tokens 500 \
    --temperature 0.3

对比测试建议

  1. 先用基础模型(不加 --adapter-path)测试同一问题
  2. 再用微调后的模型测试
  3. 记录两者差异,评估微调效果

6.2 批量评估脚本

# evaluate_model.py
import subprocess
import json

def evaluate(test_cases: list, adapter_path: str = None):
    results = []
    base_cmd = [
        "python", "-m", "mlx_lm.generate",
        "--model", "./models/Qwen2.5-1.5B-Instruct",
        "--max-tokens", "500",
        "--temperature", "0.3"
    ]
    
    if adapter_path:
        base_cmd.extend(["--adapter-path", adapter_path])
    
    for case in test_cases:
        prompt = f"User: {case}\n\nAssistant: <|thought|>"
        cmd = base_cmd + ["--prompt", prompt]
        
        result = subprocess.run(cmd, capture_output=True, text=True)
        output = result.stdout.strip()
        
        results.append({
            "input": case,
            "output": output,
            "has_thought": "<|thought|>" in output,
            "has_solution": "<|solution|>" in output
        })
    
    return results

# 运行对比
test_cases = [
    "鸡兔同笼,头共35,脚共94,鸡兔各几只?",
    "计算 125 × 32 的简便方法"
]

base_results = evaluate(test_cases)
lora_results = evaluate(test_cases, "./output/qwen_reasoning_adapter")

# 分析:lora_results 应显示更规范的思考步骤

6.3 模型融合(可选)

如果希望部署独立模型(无需每次加载 adapter):

python -m mlx_lm.fuse \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --adapter-path ./output/qwen_reasoning_adapter \
    --save-path ./models/Qwen-1.5B-Reasoning-Fused \
    --de-quantize  # 如果需要恢复为 FP16 精度

融合 vs Adapter 对比

方式优点缺点
Adapter 分离体积小(~50MB)、可多版本切换加载时需合并,略慢
融合模型单一文件、部署简单体积大(~3GB)、版本管理难

第七章:生产环境部署建议

7.1 模型量化(优化推理速度)

# 将融合后的模型量化为 4-bit(适合移动端部署)
python -m mlx_lm.convert \
    --hf-path ./models/Qwen-1.5B-Reasoning-Fused \
    --mlx-path ./models/Qwen-1.5B-Reasoning-4bit \
    --quantize --q-bits 4

7.2 持续迭代流程

原始数据 → 清洗过滤 → 格式转换 → 训练 → 评估 →  bad cases 回流 → 数据增强 → 重新训练
     ↑___________________________________________________________|

7.3 关键检查点总结

阶段必做检查常见问题
环境准备mlx-lm 版本 >= 0.8旧版本 API 不兼容
数据转换验证 JSONL 格式正确性特殊字符未转义导致解析失败
训练启动确认加载了验证集过拟合无法及时发现
训练过程监控 loss 曲线学习率过大导致 nan
推理测试对比基线模型模板格式与训练时不一致

附录:故障排查速查表

现象可能原因解决方案
RuntimeError: MPS out of memory序列过长或 batch size 过大减小 --max-seq-length--batch-size
KeyError: 'text'数据格式错误检查 JSONL 是否包含 text 字段
Loss 不下降学习率过低或数据质量问题增大 lr 至 5e-5,检查数据标签
生成结果乱码分词器不匹配确保使用与训练时相同的 tokenizer
模型不遵循指令模板格式不一致推理 prompt 必须与训练模板完全匹配

【AI】RAG技术介绍与实操

【AI】RAG技术介绍与实操

本文介绍了AI领域RAG增强检索生成技术的介绍和实操记录

从零开始构建本地RAG系统:基于Ollama的DeepSeek-R1与Nomic-Embedd实战

大语言模型的出现改变了我们与信息交互的方式,但这些模型的知识局限于训练数据的截止日期,无法覆盖实时信息和企业私有文档。检索增强生成(Retrieval-Augmented Generation, RAG)通过为模型配备外部知识库,巧妙地解决了这一难题。本文将带领你从零开始,使用Ollama平台拉取DeepSeek-R1推理模型和Nomic-Embed-Text向量化模型,构建一个完整的本地RAG系统。

一、RAG的出现:解决大模型的知识困境

1.1 大模型的固有局限

大型语言模型虽然在语言理解和生成方面表现惊艳,但它们存在两个根本性缺陷:

知识时效性问题:模型的训练数据有严格的截止日期,对于新近发生的事件或最新研究成果一无所知。GPT-4的知识截止于2023年,当你询问2024年的新闻时,它要么无法回答,要么提供过时信息。

私有数据缺失问题:公开训练数据无法覆盖企业的内部文档、产品手册或个人知识库。如果你想让模型回答关于公司内部流程的问题,仅靠通用模型是无能为力的。

更严重的是,当模型面对超出其知识范围的问题时,它不会坦诚地说”不知道”,而是会产生”幻觉”(Hallucination)——自信地编造出看似合理但完全错误的信息。这在企业应用中是不可接受的。

1.2 RAG的核心思想:开卷考试

RAG的核心理念很简单:在回答问题前,先让模型”查阅资料”。就像开卷考试允许学生翻阅教材,RAG系统在执行生成任务前,会从一个外部知识库中检索出最相关的信息,然后将这些信息作为上下文提供给模型。

这种方法带来的优势是显著的:

  • 事实准确性提升:模型的回答基于检索到的真实信息,而非凭空编造
  • 知识可更新:只需更新知识库,无需重新训练模型
  • 来源可追溯:系统可以提供答案的信息来源,增强可信度
  • 领域适应性:通过更换知识库,同一个模型可以适配不同专业领域

RAG最早由Facebook AI Research在2020年提出,他们使用维基百科作为外部知识库,通过Dense Passage Retrieval(DPR)技术检索相关文本片段,然后输入给BART生成模型。自那时起,RAG迅速普及,成为AI应用开发的核心范式。

二、RAG向量化流程详解

RAG系统的核心在于将非结构化文本转换为机器可计算的向量表示。这一过程涉及文档加载、文本切分、向量化生成和向量存储四个关键步骤。

2.1 文档加载与切分

原始知识库通常是各种格式的文档——TXT、PDF、Markdown或网页。构建RAG的第一步是加载这些文档并将其切分成适合检索的文本块。

为什么需要切分? 大模型有上下文长度限制,直接将整本手册或整篇文章作为上下文既不现实也不高效。切分的目标是:每个文本块应包含相对完整的语义单元

切分策略有多种选择:

按换行符切分是最简单直接的方式,适用于行结构清晰的文档:

def split_content(content):
    chunks = []
    lines = content.splitlines()
    for line in lines:
        if line.strip():  # 忽略空行
            chunks.append(line)
    return chunks

递归字符文本切分更智能,它会尝试按段落、句子、单词的顺序逐步切分,尽可能保持语义完整性。实践中,chunk_size通常设为500-1000个字符,chunk_overlap设为50-100个字符,以保留上下文连贯性。

2.2 向量化:文本的数学表达

切分后的文本块需要转换为计算机可以理解和计算的形式——向量(Vector),也称为嵌入(Embedding)

向量的本质可以这样理解:想象我们要给每段文字拍一张特殊的”照片”,这张照片由几百个数字组成(如768个维度)。这张”照片”就是该段文字的”语义身份证”——语义相近的文字,它们的”照片”在数字空间中的距离也会很近。

这个转换过程由嵌入模型(Embedding Model) 完成。嵌入模型经过海量文本训练,学会了将文字映射到高维语义空间。在本文的实践中,我们将使用Ollama拉取的nomic-embed-text模型,它生成的向量维度为768维。

向量化的核心原则是:语义相似,向量相近。这意味着:

  • “苹果是一种水果”和”香蕉富含钾元素”的向量距离较近
  • “苹果是一种水果”和”Python是一种编程语言”的向量距离较远

2.3 向量存储

生成向量后,我们需要一个专门的基础设施来存储和检索这些向量。虽然传统数据库(如MySQL)也能存储向量,但它们无法进行高效的相似性搜索。

向量数据库(Vector Database) 专为存储和查询高维向量而设计,内置了高效的近似最近邻(ANN)搜索算法,可以在毫秒级内从数百万向量中找出最相似的几个。

常见的向量数据库包括FAISS、ChromaDB、Milvus等。在本文的实践中,考虑到我们追求理解原理而非生产部署,将使用FAISS或甚至直接使用NumPy数组存储向量——这足以让我们看清RAG的本质。

三、RAG推理检索流程

当用户提出问题后,RAG系统进入在线推理阶段。这一阶段包括查询向量化、相似度检索、结果重排和提示词构建四个环节。

3.1 查询向量化与相似度检索

用户输入的问题同样需要转换为向量——必须使用与知识库向量化完全相同的嵌入模型。这样才能确保查询向量和文档向量位于同一个语义空间,距离计算才有意义。

接下来,系统计算查询向量与知识库中所有文档向量的相似度。最常用的相似度指标是余弦相似度(Cosine Similarity)

def similarity(e1, e2):
    # 计算余弦相似度
    dot_product = np.dot(e1, e2)                     # 点乘
    norm_e1 = np.linalg.norm(e1)                     # 向量1的范数
    norm_e2 = np.linalg.norm(e2)                     # 向量2的范数
    cosine_sim = dot_product / (norm_e1 * norm_e2)   # 余弦相似度
    return cosine_sim

余弦相似度的取值范围是[-1, 1],值越大表示两个向量的夹角越小,语义越相近。

计算出所有相似度后,系统按分数从高到低排序,取前K个(通常K=3~5)最相似的文本块作为检索结果。

3.2 结果重排(可选优化)

基础的向量检索存在一个潜在问题:排名靠前的文档不一定是最有用的。检索阶段使用的是双编码器(Bi-Encoder) 架构,它将查询和文档分别编码,速度快但精度有限。

为了提高质量,可以引入重排(Reranking) 阶段。重排使用交叉编码器(Cross-Encoder) 架构,它将查询和文档拼接后一起输入模型,计算相关性得分,精度更高但速度慢。因此,典型的策略是:向量检索先快速召回Top 100,重排模型再从中精筛Top 5。

在本文的简化实现中,我们将跳过重排步骤,直接使用向量检索结果。

3.3 提示词构建与生成

检索到相关文档后,需要将它们与用户问题组装成一个结构化的提示词(Prompt),然后发送给大语言模型。

最简单的提示词模板如下:

prompt_template = """
基于以下知识回答问题:

知识:
1: %s
2: %s
3: %s
4: %s
5: %s

问题:%s

请基于上述知识给出准确、详细的回答。如果知识中不包含相关信息,请明确说明。
"""

这个模板的设计原则是:

  • 明确信息来源:告知模型知识是从哪里来的
  • 限定回答范围:要求模型”基于知识”回答,减少幻觉
  • 允许不知道:为模型提供”不知道”的出口,避免编造

将组装好的提示词发送给大语言模型(本文使用DeepSeek-R1),模型生成的回答就是最终输出。

四、输出整理与优化

RAG系统的输出并非终点,还需要进行整理和可能的优化。

4.1 引用来源

高质量的RAG系统应该在回答中标注信息来源。这类似于学术论文的脚注,用户可以核实每个事实的真实性。在实践中,可以在返回检索结果时保留文档的元数据(如文件名、段落位置),然后在生成回答时要求模型引用来源编号。

4.2 查询重写(高级优化)

用户提问往往含糊不清或包含隐含意图。例如,用户问”告诉我NVIDIA模型的最新更新”,可能暗中对特定功能感兴趣,但这种偏好没有被明确表达。

查询重写(Query Rewriting) 技术可以在检索前优化用户查询,弥合用户提问方式与知识库信息结构之间的语义差距。常用方法包括:

  • Q2E(Query2Expand):生成同义词和相关短语,扩展查询
  • Q2D(Query2Doc):根据查询构建伪文档,匹配文档风格
  • CoT(思维链)查询重写:让模型逐步推理,分解查询意图

研究表明,使用Llama 3.3 Nemotron Super 49B进行CoT查询重写后,检索准确率@10从43.1%提升至63.8%。

五、串联运行:完整Python脚本实现

现在,让我们将所有环节串联起来,编写一个完整的RAG系统Python脚本。本实现将最小化第三方框架依赖,仅使用Ollama和NumPy,确保你能够看清每个步骤的本质。

5.1 环境准备

首先,确保已安装Ollama并拉取所需模型:

# 安装Ollama(请访问ollama.com下载对应系统版本)

# 拉取模型
ollama pull deepseek-r1:8b
ollama pull nomic-embed-text

# 安装Python依赖
pip install ollama numpy

环境问题

系统python 3.9缺失

这个错误是因为Python 3.9不支持|操作符用于类型联合(type union)。在Python 3.10及以上版本中,|才被支持用于类型提示。你的代码运行在Python 3.9环境,所以报错。

需要安装python 3.10.X 使用pyenv工具,切换用户层级的python环境,

Rust编译器缺失:安装tiktoken时报错can’t find Rust compiler

SWIG工具缺失:报错command ‘swig’ failed: No such file or directory

FAISS编译失败:报错command ‘/opt/homebrew/bin/swig’ failed with exit code 1,FAISS安装失败

PyPy兼容性问题

发现你在使用PyPy而不是CPython,导致安装faiss和chromadb均报错。

重置步骤:

# 1. 确保使用 CPython
pyenv install 3.10.15
cd /Users/mac/Dev/Desktop/RagDemo
pyenv local 3.10.15

# 2. 创建干净的环境
python -m venv venv
source venv/bin/activate

# 3. 升级基础工具
pip install --upgrade pip setuptools wheel

# 4. 安装依赖(按顺序)
pip install chromadb
pip install llama-index-core
pip install llama-index-embeddings-ollama
pip install llama-index-llms-ollama
pip install llama-index-vector-stores-chroma

# 5. 验证安装
pip list | grep -E "chroma|llama"

5.2 完整代码实现

"""
基于LlamaIndex的多文件RAG系统
处理文件夹下所有文件,支持多种文件格式
显示DeepSeek-R1的完整思考过程
"""

import os
from typing import List, Generator, Optional
import warnings
warnings.filterwarnings('ignore')

# LlamaIndex核心组件
from llama_index.core import (
    SimpleDirectoryReader,
    VectorStoreIndex,
    Settings,
    Document
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import CompactAndRefine
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.storage import StorageContext

# Ollama集成
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama

# 向量存储
from llama_index.vector_stores.faiss import FaissVectorStore
import faiss

# 文件监控(可选)
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler


class MultiFileRAGSystem:
    """
    多文件RAG系统
    支持文件夹下所有文件,自动处理格式转换
    """

    SUPPORTED_EXTENSIONS = [
        '.txt', '.pdf', '.docx', '.pptx', '.xlsx',
        '.md', '.csv', '.epub', '.html', '.htm',
        '.json', '.xml', '.ipynb'
    ]

    def __init__(
        self,
        docs_dir: str,
        embed_model_name: str = "nomic-embed-text",
        llm_model_name: str = "deepseek-r1:8b",
        ollama_base_url: str = "http://localhost:11434",
        chunk_size: int = 512,
        chunk_overlap: int = 50,
        similarity_top_k: int = 5,
        persist_dir: Optional[str] = "./storage"
    ):
        """
        初始化多文件RAG系统

        Args:
            docs_dir: 文档目录路径
            embed_model_name: 嵌入模型名称
            llm_model_name: 大语言模型名称
            ollama_base_url: Ollama服务地址
            chunk_size: 文本块大小
            chunk_overlap: 文本块重叠大小
            similarity_top_k: 检索返回的相似文档数量
            persist_dir: 索引持久化目录(可选)
        """
        self.docs_dir = docs_dir
        self.persist_dir = persist_dir
        self.similarity_top_k = similarity_top_k
        self.thinking_buffer = ""
        self.answer_buffer = ""

        print("=" * 80)
        print("多文件RAG系统初始化")
        print("=" * 80)
        print(f"📁 文档目录: {docs_dir}")
        print(f"📊 支持的文件格式: {', '.join(self.SUPPORTED_EXTENSIONS[:5])}等")

        # 检查目录是否存在
        if not os.path.exists(docs_dir):
            raise ValueError(f"文档目录不存在: {docs_dir}")

        # 1. 配置嵌入模型
        print(f"\n🔧 加载嵌入模型: {embed_model_name}")
        self.embed_model = OllamaEmbedding(
            model_name=embed_model_name,
            base_url=ollama_base_url,
            ollama_additional_kwargs={"mirostat": 0}
        )

        # 2. 配置大语言模型
        print(f"🔧 加载语言模型: {llm_model_name}")
        self.llm = Ollama(
            model=llm_model_name,
            base_url=ollama_base_url,
            temperature=0.7,
            request_timeout=120.0,
            additional_kwargs={
                "num_predict": 2048,
                "top_k": 40,
                "top_p": 0.9
            }
        )

        # 3. 设置全局配置
        Settings.embed_model = self.embed_model
        Settings.llm = self.llm
        Settings.chunk_size = chunk_size
        Settings.chunk_overlap = chunk_overlap

        # 4. 初始化文本切分器
        self.node_parser = SentenceSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separator=" ",
            paragraph_separator="\n\n",
            secondary_chunking_regex="[^,.;:]+[,.;:]?"
        )

        # 5. 索引对象
        self.index = None
        self.query_engine = None

        # 6. 文件统计信息
        self.file_stats = {}

        print("✅ 初始化完成")

    def scan_documents(self) -> dict:
        """
        扫描文档目录,统计文件信息

        Returns:
            文件统计信息
        """
        print(f"\n📋 扫描文档目录: {self.docs_dir}")

        stats = {
            "total_files": 0,
            "supported_files": 0,
            "unsupported_files": 0,
            "file_types": {},
            "total_size_mb": 0
        }

        for root, dirs, files in os.walk(self.docs_dir):
            for file in files:
                file_path = os.path.join(root, file)
                file_ext = os.path.splitext(file)[1].lower()
                file_size = os.path.getsize(file_path) / (1024 * 1024)  # MB

                stats["total_files"] += 1
                stats["total_size_mb"] += file_size

                if file_ext in self.SUPPORTED_EXTENSIONS:
                    stats["supported_files"] += 1
                    stats["file_types"][file_ext] = stats["file_types"].get(file_ext, 0) + 1
                    print(f"  ✅ {file} ({file_size:.2f} MB) - {file_ext}")
                else:
                    stats["unsupported_files"] += 1
                    print(f"  ⚠️ {file} ({file_size:.2f} MB) - 不支持格式: {file_ext}")

        print(f"\n📊 统计结果:")
        print(f"  总文件数: {stats['total_files']}")
        print(f"  支持文件: {stats['supported_files']}")
        print(f"  不支持文件: {stats['unsupported_files']}")
        print(f"  总大小: {stats['total_size_mb']:.2f} MB")
        print(f"  文件类型分布: {stats['file_types']}")

        self.file_stats = stats
        return stats

    def load_documents(self, recursive: bool = True) -> List[Document]:
        """
        加载文档目录下的所有文件

        Args:
            recursive: 是否递归加载子目录

        Returns:
            文档列表
        """
        print(f"\n📄 加载文档...")

        # 首先扫描文件
        self.scan_documents()

        # 使用SimpleDirectoryReader加载所有文件
        reader = SimpleDirectoryReader(
            input_dir=self.docs_dir,
            recursive=recursive,
            exclude_hidden=True,  # 排除隐藏文件
            required_exts=self.SUPPORTED_EXTENSIONS  # 只加载支持的文件
        )

        documents = reader.load_data()

        print(f"\n✅ 成功加载 {len(documents)} 个文档")

        # 按文件类型显示统计
        doc_by_type = {}
        for doc in documents:
            file_name = doc.metadata.get('file_name', 'unknown')
            file_ext = os.path.splitext(file_name)[1].lower()
            doc_by_type[file_ext] = doc_by_type.get(file_ext, 0) + 1

        print("📊 文档类型分布:")
        for ext, count in doc_by_type.items():
            print(f"  {ext}: {count} 个文档")

        # 显示每个文档的信息
        for i, doc in enumerate(documents[:5]):  # 只显示前5个
            file_name = doc.metadata.get('file_name', 'unknown')
            file_size = doc.metadata.get('file_size', 0) / 1024  # KB
            preview = doc.text[:100].replace('\n', ' ') + "..." if len(doc.text) > 100 else doc.text
            print(f"\n  文档 {i+1}: {file_name}")
            print(f"    大小: {file_size:.1f} KB")
            print(f"    预览: {preview}")

        if len(documents) > 5:
            print(f"\n  ... 还有 {len(documents) - 5} 个文档未显示")

        return documents

    def build_index(self, documents: List[Document], force_rebuild: bool = False):
        """
        构建向量索引

        Args:
            documents: 文档列表
            force_rebuild: 是否强制重建(忽略已有索引)
        """
        # 检查是否有持久化索引
        if self.persist_dir and os.path.exists(self.persist_dir) and not force_rebuild:
            try:
                print(f"\n🔍 发现已有索引,尝试加载...")
                self.load_index()
                return
            except Exception as e:
                print(f"⚠️ 加载索引失败: {e}")
                print("将重新构建索引...")

        print(f"\n🔨 构建向量索引...")
        print(f"切分文档为文本块 (size={Settings.chunk_size}, overlap={Settings.chunk_overlap})")

        # 构建索引(显示进度)
        self.index = VectorStoreIndex.from_documents(
            documents,
            embed_model=self.embed_model,
            node_parser=self.node_parser,
            show_progress=True
        )

        print(f"✅ 索引构建完成")

        # 获取节点统计
        nodes = self.index.docstore.docs.values()
        print(f"  生成文本块数量: {len(nodes)}")

        # 计算平均文本块长度
        avg_length = sum(len(node.text) for node in nodes) / len(nodes)
        print(f"  平均文本块长度: {avg_length:.1f} 字符")

        # 保存索引
        if self.persist_dir:
            self.save_index()

    def setup_query_engine(self):
        """
        配置查询引擎
        """
        if not self.index:
            raise ValueError("请先构建索引")

        print(f"\n⚙️ 配置查询引擎...")

        # 1. 创建检索器
        retriever = VectorIndexRetriever(
            index=self.index,
            similarity_top_k=self.similarity_top_k,
            embed_model=self.embed_model
        )

        # 2. 创建响应合成器(启用流式输出)
        response_synthesizer = CompactAndRefine(
            llm=self.llm,
            streaming=True,
            verbose=True
        )

        # 3. 创建后处理器
        postprocessor = SimilarityPostprocessor(similarity_cutoff=0.6)  # 降低阈值以包含更多结果

        # 4. 组合为查询引擎
        self.query_engine = RetrieverQueryEngine(
            retriever=retriever,
            response_synthesizer=response_synthesizer,
            node_postprocessors=[postprocessor]
        )

        print(f"✅ 查询引擎配置完成")
        print(f"  检索数量: top_{self.similarity_top_k}")
        print(f"  相似度阈值: 0.6")

    def query_with_thinking(self, query: str) -> Generator[dict, None, None]:
        """
        执行查询并流式返回思考过程

        Args:
            query: 用户查询

        Yields:
            包含类型和内容的字典
        """
        if not self.query_engine:
            raise ValueError("请先配置查询引擎")

        print(f"\n🔍 执行查询: '{query}'")

        # 1. 执行检索
        nodes = self.query_engine.retriever.retrieve(query)

        # 组织检索结果
        retrieval_results = []
        for node in nodes:
            file_name = node.metadata.get('file_name', '未知文件')
            retrieval_results.append({
                "text": node.text,
                "score": node.score,
                "file_name": file_name,
                "node_id": node.node_id
            })

        # 显示检索结果
        yield {
            "type": "retrieval",
            "content": {
                "query": query,
                "results": retrieval_results
            }
        }

        # 2. 构建提示词(带文件来源信息)
        context_parts = []
        for i, node in enumerate(nodes):
            file_name = node.metadata.get('file_name', '未知文件')
            context_parts.append(f"[来自文件: {file_name}]\n{node.text}")

        context_text = "\n\n---\n\n".join(context_parts)
        prompt = f"""
基于以下从多个文件中检索到的知识回答问题。每个知识片段都标注了来源文件。

{context_text}

用户问题:{query}

请严格基于上述知识回答,不要编造信息。如果知识中不包含相关信息,请明确说明。
在回答中可以提及信息来源文件(如"根据XXX文件的描述")。
"""

        yield {"type": "prompt", "content": prompt[:300] + "..." if len(prompt) > 300 else prompt}

        # 3. 执行查询(流式输出)
        yield {"type": "start_generation", "content": "开始生成回答..."}

        streaming_response = self.query_engine.query(query)

        # 重置缓冲区
        self.thinking_buffer = ""
        self.answer_buffer = ""
        in_thinking = True

        # 流式处理响应
        for text_chunk in streaming_response.response_gen:
            # 检测思考/回答边界
            if "思考:" in text_chunk or "分析:" in text_chunk:
                in_thinking = True
                self.thinking_buffer += text_chunk
                yield {"type": "thinking_chunk", "content": text_chunk}
            elif "答案:" in text_chunk or "回答:" in text_chunk:
                if in_thinking:
                    yield {"type": "thinking_end", "content": ""}
                in_thinking = False
                self.answer_buffer += text_chunk
                yield {"type": "answer_chunk", "content": text_chunk}
            elif in_thinking:
                self.thinking_buffer += text_chunk
                yield {"type": "thinking_chunk", "content": text_chunk}
            else:
                self.answer_buffer += text_chunk
                yield {"type": "answer_chunk", "content": text_chunk}

        # 4. 返回完整结果(带来源信息)
        source_nodes = [
            {
                "text": node.text,
                "score": node.score,
                "file_name": node.metadata.get('file_name', '未知文件'),
                "metadata": node.metadata
            }
            for node in streaming_response.source_nodes
        ]

        yield {
            "type": "complete",
            "content": {
                "thinking": self.thinking_buffer,
                "answer": streaming_response.response,
                "source_nodes": source_nodes
            }
        }

    def save_index(self):
        """保存索引到磁盘"""
        if not self.index or not self.persist_dir:
            return

        os.makedirs(self.persist_dir, exist_ok=True)
        self.index.storage_context.persist(persist_dir=self.persist_dir)
        print(f"✅ 索引已保存到 {self.persist_dir}")

    def load_index(self):
        """从磁盘加载索引"""
        from llama_index.core import load_index_from_storage

        if not os.path.exists(self.persist_dir):
            raise ValueError(f"持久化目录不存在: {self.persist_dir}")

        storage_context = StorageContext.from_defaults(persist_dir=self.persist_dir)
        self.index = load_index_from_storage(storage_context)
        print(f"✅ 索引已从 {self.persist_dir} 加载")


class DocumentWatcher(FileSystemEventHandler):
    """文档目录监控器,当文件变化时自动重建索引"""

    def __init__(self, rag_system: MultiFileRAGSystem):
        self.rag_system = rag_system
        self.last_rebuild = time.time()
        self.rebuild_cooldown = 60  # 60秒内不重复重建

    def on_modified(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def on_created(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def on_deleted(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def _handle_change(self, file_path):
        """处理文件变化"""
        current_time = time.time()
        if current_time - self.last_rebuild > self.rebuild_cooldown:
            print(f"\n🔄 检测到文件变化: {file_path}")
            print("正在重建索引...")
            self.rag_system.build_index(
                self.rag_system.load_documents(),
                force_rebuild=True
            )
            self.last_rebuild = current_time


def interactive_mode():
    """交互式问答模式"""

    # 配置
    docs_dir = "./blog_docs"  # 存放所有知识库文件的目录

    # 初始化RAG系统
    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        embed_model_name="nomic-embed-text",
        llm_model_name="deepseek-r1:8b",
        chunk_size=512,
        chunk_overlap=50,
        similarity_top_k=5,
        persist_dir="./storage"  # 启用持久化
    )

    # 加载文档并构建索引
    documents = rag.load_documents(recursive=True)
    rag.build_index(documents)
    rag.setup_query_engine()

    # 交互式问答
    print("\n" + "=" * 80)
    print("多文件RAG系统已就绪!输入问题开始问答(输入 'exit' 退出)")
    print("=" * 80)

    while True:
        query = input("\n📝 【用户】: ").strip()
        if query.lower() in ['exit', 'quit', '退出']:
            print("再见!")
            break

        if not query:
            continue

        print("\n🔄 【系统】正在处理...")
        print("-" * 80)

        thinking_mode = True
        sources_shown = False

        for event in rag.query_with_thinking(query):
            if event["type"] == "retrieval":
                print("\n📚 【检索结果】")
                for i, result in enumerate(event["content"]["results"]):
                    preview = result["text"][:60] + "..." if len(result["text"]) > 60 else result["text"]
                    print(f"  [{i+1}] 相关度: {result['score']:.4f} | 来源: {result['file_name']}")
                    print(f"      内容: {preview}")

            elif event["type"] == "prompt":
                print(f"\n📝 【提示词预览】\n  {event['content']}")

            elif event["type"] == "start_generation":
                print("\n🧠 【模型思考中】")

            elif event["type"] == "thinking_chunk":
                print(event["content"], end='', flush=True)

            elif event["type"] == "thinking_end":
                print("\n\n💡 【思考完成,开始回答】\n")
                thinking_mode = False

            elif event["type"] == "answer_chunk":
                print(event["content"], end='', flush=True)

            elif event["type"] == "complete" and not sources_shown:
                print("\n\n📊 【信息来源】")
                sources = {}
                for node in event["content"]["source_nodes"]:
                    file_name = node["file_name"]
                    if file_name not in sources:
                        sources[file_name] = []
                    sources[file_name].append({
                        "score": node["score"],
                        "preview": node["text"][:50] + "..."
                    })

                for file_name, snippets in sources.items():
                    print(f"\n  📄 {file_name}:")
                    for i, snippet in enumerate(snippets):
                        print(f"    [{i+1}] 相关度: {snippet['score']:.4f}")
                        print(f"        {snippet['preview']}")

                sources_shown = True

        print("\n" + "=" * 80)


def batch_mode():
    """批量处理模式:处理多个问题"""

    # 配置
    docs_dir = "./blog_docs"

    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        persist_dir="./storage"
    )

    # 加载文档并构建索引
    documents = rag.load_documents()
    rag.build_index(documents)
    rag.setup_query_engine()

    # 从文件读取问题列表
    questions_file = "questions.txt"
    if os.path.exists(questions_file):
        with open(questions_file, 'r', encoding='utf-8') as f:
            questions = [q.strip() for q in f.readlines() if q.strip()]
    else:
        # 示例问题
        questions = [
            "苹果有什么营养价值?",
            "香蕉适合什么时候吃?",
            "如何保持健康饮食?"
        ]

    print("\n" + "=" * 80)
    print("批量处理模式")
    print("=" * 80)

    results = []
    for i, question in enumerate(questions, 1):
        print(f"\n📌 问题 {i}/{len(questions)}: {question}")
        print("-" * 40)

        response = rag.query_engine.query(question)
        print(f"回答: {response}")

        results.append({
            "question": question,
            "answer": str(response),
            "sources": [
                {
                    "file": node.metadata.get('file_name', '未知'),
                    "score": node.score,
                    "text": node.text[:200]
                }
                for node in response.source_nodes
            ]
        })

        print()

    # 保存结果
    import json
    with open("batch_results.json", "w", encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    print("✅ 结果已保存到 batch_results.json")


def watch_mode():
    """监控模式:监听文件变化并自动更新索引"""

    # 配置
    docs_dir = "./blog_docs"

    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        persist_dir="./storage"
    )

    # 初始构建
    documents = rag.load_documents()
    rag.build_index(documents)
    rag.setup_query_engine()

    # 启动文件监控
    event_handler = DocumentWatcher(rag)
    observer = Observer()
    observer.schedule(event_handler, docs_dir, recursive=True)
    observer.start()

    print("\n" + "=" * 80)
    print("监控模式已启动 - 文件变化时将自动重建索引")
    print("输入问题开始问答(输入 'exit' 退出)")
    print("=" * 80)

    try:
        while True:
            query = input("\n📝 【用户】: ").strip()
            if query.lower() in ['exit', 'quit', '退出']:
                break

            if not query:
                continue

            response = rag.query_engine.query(query)
            print(f"\n💬 【回答】: {response}")

            # 显示来源
            print("\n📚 【来源】:")
            for node in response.source_nodes[:3]:
                file_name = node.metadata.get('file_name', '未知')
                print(f"  - {file_name} (相关度: {node.score:.4f})")

    finally:
        observer.stop()
        observer.join()


def main():
    """主函数:选择运行模式"""

    print("多文件RAG系统 - 选择运行模式:")
    print("1. 交互式问答")
    print("2. 批量处理")
    print("3. 监控模式(自动更新)")

    choice = input("\n请输入选择 (1/2/3): ").strip()

    if choice == "1":
        interactive_mode()
    elif choice == "2":
        batch_mode()
    elif choice == "3":
        watch_mode()
    else:
        print("无效选择,运行默认交互式模式")
        interactive_mode()


if __name__ == "__main__":
    main()

代码解析:

系统启动时,首先通过MultiFileRAGSystem类的构造函数完成基础配置。初始化过程包括设置文档目录路径、配置Ollama服务连接的嵌入模型(默认使用nomic-embed-text)和大语言模型(默认使用deepseek-r1:1.5b),以及定义文本处理参数如块大小512字符、块重叠50字符和检索返回数量top_k=5。同时,系统会检查文档目录是否存在,配置全局Settings对象,初始化文本切分器,并准备持久化存储目录。

系统通过scan_documents方法递归扫描指定目录下的所有文件,统计总文件数、支持的文件格式(包括txt、pdf、docx、pptx、xlsx、md、csv等十多种格式)、文件大小分布等信息,并识别不支持的文件格式。随后,load_documents方法调用SimpleDirectoryReader加载所有支持格式的文档,将每个文件转换为Document对象,保留文件名、文件大小等元数据信息。

在索引构建阶段,系统使用SentenceSplitter将加载的文档切分为固定大小的文本块(节点),每个节点包含文本内容和来源元数据。通过OllamaEmbedding将文本块转换为向量表示,并存储到Faiss向量数据库中构建VectorStoreIndex。如果启用了持久化功能,索引会被保存到磁盘,方便后续快速加载而无需重复构建。构建完成后,系统会统计生成的文本块数量和平均长度。

setup_query_engine方法负责配置完整的查询流水线。首先创建VectorIndexRetriever检索器,设置相似度检索的top_k参数。然后创建CompactAndRefine响应合成器,启用流式输出和详细模式。接着配置SimilarityPostprocessor后处理器,设置相似度阈值为0.6以过滤低质量结果。最后将这些组件组合成RetrieverQueryEngine,形成完整的查询处理链路。

当用户提交查询时,系统执行query_with_thinking方法,该方法设计为生成器函数以支持流式输出。查询处理分为多个阶段:

首先是检索阶段,系统将用户问题向量化后到索引中检索最相似的top_k个文本块,返回每个块的文本内容、相似度分数和来源文件信息,这些结果以"retrieval"类型的事件流式输出。

其次是提示词构建阶段,系统将检索到的文本块按照来源文件组织,构建包含完整上下文信息的提示词,明确标注每个知识片段的来源文件,要求LLM严格基于提供的知识回答,并可提及信息来源。构建的提示词以"prompt"类型事件输出。

然后是流式生成阶段,系统调用查询引擎执行查询,获取流式响应。在生成过程中,系统实时分析输出的文本流,智能区分"思考过程"和"最终答案"两部分。通过检测"思考:"、"分析:"等关键词标记思考过程的开始,检测"答案:"、"回答:"等关键词标记思考与回答的边界。思考过程的每个文本块以"thinking_chunk"类型输出,回答部分以"answer_chunk"类型输出,实现类似DeepSeek-R1的思考过程展示效果。

最后是结果整合阶段,生成完成后,系统收集完整的思考过程和最终答案,同时整理所有引用的源节点信息,包括每个节点的文本预览、相似度分数和来源文件,以"complete"类型事件返回完整结果。

三种运行模式详解
交互式模式是系统的主要使用方式,初始化RAG系统后进入问答循环。用户输入问题后,系统实时展示检索到的相关文本块及其相似度分数,然后逐字显示模型的思考过程,接着显示最终生成的答案,最后列出所有信息来源文件及具体引用的文本片段,实现完全透明的问答过程。

批量处理模式适用于需要处理多个预定义问题的场景。系统从questions.txt文件读取问题列表,或使用内置示例问题,依次处理每个问题。每个问题的回答结果和来源信息被收集并保存为结构化的JSON文件,便于后续分析和处理。

监控模式通过watchdog库实现文件系统的实时监控。当检测到文档目录中的文件被修改、创建或删除时,系统自动触发索引重建,确保知识库的实时性。同时,用户仍可进行问答交互,系统会使用最新的索引提供服务。为防止频繁重建,设置了60秒的重建冷却时间。

运行效果对比

直接在命令行中运行提问

>>> 安卓设备开机流程有哪些
Thinking...
好的,用户之前问过关于“helolo”的意思,我解释了一下,然后他又问了“你好”。看起来他是在确认是否能直接使用我的回答。现在他
询问“安卓设备开机流程有哪些”,这显然是在寻求指导。

我觉得可能有些疑问或者对安卓设备操作感兴趣。所以,我决定详细介绍一下安卓设备的开机步骤,包括启动程序、设置软件和完成一
些基本的用户操作。

可能他有新手,或者是想确认一些基本信息来更好地使用手机或电脑,比如设置、任务管理等。我的回答要尽量简单易懂,避免太专业
的术语,让他能够轻松理解。

另外,我还用了感叹号加强语气,显得比较热情和亲切,希望他能对我的建议有所认可。这样不仅帮助他解决了问题,还增加了与用户
的互动感。
...done thinking.

好的!安卓设备开机流程主要分为以下几步:

---

### **第一步:找到并启动操作系统**
1. 打开手机或电脑。
2. 在桌面上找到“应用商店”(在Windows系统中是“Settings”,在Mac系统中是“Programmer”)。
3. 点击“应用商店”后,找到并打开“Android”或“安卓”应用商店(根据你使用的设备类型而定)。

---

### **第二步:启动目标设备**
1. 打开“设置”(如“ Settings”在Windows系统中,或“System Settings”在Mac系统中)。
2. 在设置窗口中找到并打开你想操作的安卓设备的操作系统版本。
3. 点击“确定”即可开始运行目标设备。

---

### **第三步:进入设置**
1. 打开“设置”,然后选择要启动的设备。
2. 设备在设备界面上会显示为一个图标或图标,点击完成即可连接到目标设备。

---

### **第四步:运行设备**
1. 确认输入并点击“确定”按钮(Windows系统)或“下一步”(Mac系统)。
2. 运行目标设备后,你可以看到设备的设置界面,可以通过右键点击设备进行更多操作。

---

### **注意事项**
- 如果你的手机或电脑没有连接到互联网,可能需要关闭网络选项以确保设备能够正确启动。
- 检查并安装必要的软件,如系统更新、应用商店和第三方工具等。

希望这对你有帮助!如果你还有其他问题,随时告诉我哦! 😊

>>> Send a message (/? for help)

RAG运行效果

📝 【用户】: 安卓设备开机流程有哪些

🔄 【系统】正在处理...
--------------------------------------------------------------------------------

🔍 执行查询: '安卓设备开机流程有哪些'

📚 【检索结果】
  [1] 相关度: 0.6225 | 来源: 2022-12-12-【Android进阶】Android设备开机流程.md
      内容: 通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改

## 执行芯片内...
  [2] 相关度: 0.6050 | 来源: 2022-12-14-【Android进阶】Android热门原理流程总结.md
      内容: ## 内存泄漏常见场景
见性能优化篇:
[【Android性能优化】内存](./2025-1-4-【Android性能优...
  [3] 相关度: 0.6010 | 来源: 2022-12-12-【Android进阶】Android设备开机流程.md
      内容: ..

## 桌面环境启动阶段
System Server启动完成后,AMS会启动Launcher应用。Launcher...
  [4] 相关度: 0.5841 | 来源: 2022-12-14-【Android进阶】Android热门依赖库知识点总结.md
      内容: * ViewModel 暴露 LiveData
* View(Activity/Fragment) 观察 LiveDat...
  [5] 相关度: 0.5775 | 来源: 2022-12-14-【Android进阶】Android热门依赖库知识点总结.md
      内容: * 当 Activity 可能被销毁时(如配置变更或后台回收),系统会调用 onSaveInstanceState(Bu...

📝 【提示词预览】
  
    基于以下从多个文件中检索到的知识回答问题。每个知识片段都标注了来源文件。

    [来自文件: 2022-12-12-【Android进阶】Android设备开机流程.md]
通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改

## 执行芯片内部BootROM代码
BootROM代码执行基本的硬件初始化,验证并加载下一阶段的引导程序(通常是从特定存储区域),实现安全验证(如验证Bootloader签名)

## 安全启动(Secure Boot)验证
现代Android设备都支持安全启动机制,BootROM会验证Bootloade...

🧠 【模型思考中】
Android设备开机流程主要包括以下几个部分:

1. **系统服务和桌面组件初始化完成**:设备显示完整的桌面环境,用户可以开始与设备交互。

2. **系统启动稍作了解即可**:除了SystemUI和Launcher外,我们更多关注的是应用层的启动流程。在Android进阶的文章中详细描述了这些内容。

3. **系统启动流程**:
   - 系统启动流程包括冷启动流程、Handler消息机制以及Activity Window初始化等部分。
   - 冷启动流程用于快速启动设备,使其处于待机状态。
   - Handler消息机制负责处理用户请求和应用信息的同步。
   - Activity Window初始化为每个应用提供一个独立的工作界面。

4. **设备进入 Bootloader模式**:在系统运行时,设备会按特定键(如Volume+Power)进入Bootloader模式。在启动完成后,Boot ROM代码会被加载并执行,确保设备安全运行。

这些内容详细涵盖了Android设备开机流程的核心步骤和过程,从系统初始化到用户界面的设置。

📊 【信息来源】

  📄 2022-12-12-【Android进阶】Android设备开机流程.md:
    [1] 相关度: 0.6225
        通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改...
    [2] 相关度: 0.6010
        ..

## 桌面环境启动阶段
System Server启动完成后,AMS会启动Launcher应...

  📄 2022-12-14-【Android进阶】Android热门原理流程总结.md:
    [1] 相关度: 0.6050
        ## 内存泄漏常见场景
见性能优化篇:
[【Android性能优化】内存](./2025-1-4-【...

================================================================================

传统模型回答(左):

完全依赖模型预训练知识,回答内容泛化且存在明显错误

将安卓设备开机流程混淆为"打开应用商店"、"启动目标设备"等错误操作

出现了"在Mac系统中是Programmer"等荒谬描述

整体回答缺乏技术深度,更像是通用设备使用指南

RAG增强回答(右):

严格基于检索到的技术文档回答问题

准确描述了BootROM、安全启动、System Server、Launcher等专业概念

引用了具体的Markdown文档作为知识来源(如2022-12-12-【Android进阶】Android设备开机流程.md)

回答内容专业、准确,符合Android开发者的技术认知

结语

本文从RAG的出现原因出发,详细讲解了向量化流程、推理检索流程和输出整理优化,并最终给出了一个完整、可运行的本地RAG系统实现。通过这个实战项目,你可以清晰地看到:RAG的本质就是检索 + 上下文增强 + 生成这三步曲。

RAG系统的本质是”开卷考试”:当面对”安卓设备开机流程”这样的专业问题时,它不像传统模型那样凭记忆”闭卷作答”(然后编造错误答案),而是先查阅技术文档资料,再基于资料给出准确回答。这种模式既保留了大语言模型的强大生成能力,又通过外部知识库弥补了其知识局限性和幻觉问题,是构建专业领域AI助手的理想方案。

当你运行这个系统,输入”健康的水果推荐”,即使知识库中从未出现”推荐”二字,系统也能通过语义相似度找到苹果、香蕉、橙子的描述——这就是向量检索的魅力。当你看到大模型基于检索到的知识给出准确回答,而不是凭空编造时,你就理解了RAG为什么能成为AI应用开发的基石。

从简单的流水线到自主的Agentic RAG,这项技术仍在快速演进。但无论多复杂的系统,其核心都是本文揭示的这些基本原理。希望这篇文章能帮助你打下坚实的基础,在RAG的世界里走得更远。

【AI】OpenClaw介绍与部署实操

【AI】OpenClaw介绍与部署实操

本文介绍了最近比较热门的Agent领域的OpenClaw工具,其运行原理,部署实战

2026年2月14日 湖北

提笔写下这篇文章时,窗外的鞭炮声正在炸响。春节将近,我也终于能静下心来,回顾一下过去三个月的人生轨迹。

从去年11月至今,我一直在跳槽后的着陆期中全力奔跑。这是一段既紧张又充实的时光——所有的精力都投入到熟悉新公司的业务中,目标是快速上手,顺利转正。幸运的是,我如愿从车机开发转向了手机端,终于可以开发自己更喜爱、更灵活的产品。更难得的是,遇到的导师和领导都是很好的人,在他们的帮助下,年前我也是顺利转正。

这三个月里,我把全部精力都扑在业务上,以至于几乎切断了和外部技术世界的联系。而这段时间,恰恰也是AI领域风云变幻的三个月。

看着各家大模型的能力进化,作为一个纯软件开发者,我不禁开始思考:如果只专注于客户端的UI渲染、状态管理、性能优化,未来被AI Agent替代的概率有多高?那些曾经让我引以为傲的手艺,在能够自主操作电脑、调用API、生成代码的AI面前,还能保持多久的不可替代性?

我开始意识到,需要跳出”写代码”的舒适区,去提升自己的架构视野。我需要了解一个完整的产品工程是如何相互配合来达成设计功能的,需要搞清楚各个技术领域的边界和上限在哪里——无论是前端、后端,还是正在重塑一切的AI。

趁着春节这段难得的大块时间,我想把这三个月的课补上。了解行业新资讯,看看AI领域又迸发了哪些新的火花。而第一个进入视野的,就是这只正在席卷技术圈的”龙虾”——OpenClaw

OpenClaw是什么

如果你最近关注科技新闻,一定见过“OpenClaw”这个名字。它以一种近乎野蛮的方式,把世界分割成了两半:一半是狂热的“逮虾户”,让AI代替自己写代码、抢门票、整理邮件,甚至试图让它自己去“打工赚钱”;另一半则在尝鲜后感慨,这家伙虽然潜力巨大,但部署起来既烧脑又烧钱。

尽管如此,OpenClaw的效应仍在持续发酵。阿里云、腾讯云等大厂连夜上线一键部署方案,Mac mini 因其被卖到断货,甚至极客们已经开始在旧手机上魔改所谓的“ClawPhone”。为什么一个开源的AI项目能引发如此大的震动?它和过去的ChatGPT这类工具有什么本质区别?

今天,我们将深入拆解OpenClaw的前世今生,并手把手教你如何将它部署到你的MacBook上,让你亲手养一只属于自己的“数字龙虾”。

OpenClaw来自哪里

OpenClaw(曾用名:Clawdbot / Moltbot)是一个开源的个人AI助理项目,由开发者Peter Steinberger于2026年1月正式发布。

它的Logo是一只龙虾,口号是 “The AI that actually does things” (真正干活的AI)。但仅凭这些标签,还不足以解释它的火爆。它的革命性在于其运行逻辑:

ChatGPT 是一个漂浮在云端的超级大脑,它能说会道,但没有“手脚”;而 OpenClaw,直接住进了你的电脑里。

以往的AI助手往往局限于云端沙盒,无法触碰你的本地文件。而OpenClaw通过部署在本地硬件(如你的MacBook),获得了操作系统层面的权限。它可以读取你的硬盘、访问你的浏览器历史、调用你的终端,从而真正做到了从“只会说”到“直接干”的进化。

曲折的发展历程

OpenClaw的诞生并非一帆风顺,其命名史堪称一部开源项目的求生欲史:

  1. 起源(2025年底): 项目最初由奥地利程序员Peter Steinberger开发,彼时他刚出售了自己的PDF工具公司,全职投入编程。项目起初在博客中以“Clawd”的名字亮相。
  2. 正式发布(2026年1月5日): 项目在GitHub上正式定名为“Clawdbot”。凭借其极强的实用性,GitHub星标迅速飙升至18.6万以上。
  3. 首次更名(1月27日): 由于被Anthropic指控商标侵权,Clawdbot被迫更名为“Moltbot”(意为蜕皮)。开发者自嘲:“Same lobster soul, new shell”(同样的龙虾灵魂,换了一身新壳)。
  4. 最终定名(1月30日): 为了彻底解决商标问题,项目最终统一命名为“OpenClaw”,寓意“开源赋能,精准高效”,并一直沿用至今。

在这一系列动荡中,项目的核心代码和功能不仅没有受损,反而借助社区的关注度实现了爆发式增长,最终引爆了2026年的AI Agent浪潮。

核心功能与架构拆解

OpenClaw之所以强大,是因为它不仅仅是一个对话机器人,而是一个完整的自动化闭环系统。它的核心架构主要由四部分构成:

  1. Gateway(网关): 这是OpenClaw的心脏,一个始终在后台运行的守护进程。它负责连接各类聊天软件(如iMessage、Telegram、WhatsApp)并处理与外界的交互。
  2. Agent(智能体): 负责驱动“大脑”思考。它接入大语言模型(如Claude、GPT或阿里云百炼),处理上下文记忆与逻辑推理。
  3. Skills(技能): 这是OpenClaw的“手”。通过人工编写好的标准工作流(Workflow),AI可以执行网页调研、浏览器自动化、访问邮箱等具体操作,准确率远超纯模型调用。
  4. Memory(记忆): 这是OpenClaw的“长期记忆”。它会将对话内容和用户偏好以文件形式保存在本地文件夹中。下次你再问起某件事,它能回忆起你几周前随口提过的一个想法。

它能帮你做什么?

  • 基础办公: 生成工作总结、自动分类文件、定时发送邮件。
  • 跨工具协同: 从钉钉接收指令,自动调用浏览器检索信息,生成文档后再同步发送至邮箱。
  • 代码开发: 自然语言生成代码、排查错误、甚至自主开发新的“Skills”来扩展自己的能力。
  • 生活助手: 定时刷新抢票、监控商品价格、自动回复消息。

四、macOS本地部署教程

最激动人心的部分来了。下面我们将以 macOS 为例,通过官方推荐的一键安装脚本,让你在本地电脑上拥有自己的 AI 助理。

⚠️ 风险提示: OpenClaw 拥有极高的系统权限。为了防止它误删文件或因 BUG 导致系统卡死,强烈建议使用备用 Mac 电脑,或做好完整的数据备份。这也是为什么 Mac mini 最近卖断货的原因——大家需要一台物理隔离的“AI 肉身”。

第一步:安装前的准备

  1. 检查系统环境: OpenClaw 基于 Node.js 运行。打开你的终端(Terminal),输入以下命令检查 Node.js 版本:
    node -v
    

    如果版本低于 v22.0.0,或提示“command not found”,请先访问 Node.js 官网 下载并安装 LTS 版本。

  2. 准备 API Key: OpenClaw 本身不带大模型,需要接入外部大脑。你可以提前准备好 Anthropic Claude、OpenAI 或国内阿里云百炼、DeepSeek 等平台的 API Key。

第二步:快速安装 OpenClaw

在终端中执行以下命令(这是官方推荐的一键安装脚本,会自动检测系统并安装依赖):

curl -fsSL https://openclaw.bot/install.sh | bash

安装完成后,脚本通常会自动进入一个名为 onboard 的交互式设置向导。如果因为某些原因中断了向导,可以随时通过以下命令重新启动:

openclaw onboard --install-daemon

第三步:初始化配置(Onboard 向导)

onboard 向导中,你需要按提示完成几项核心配置:

  1. AI 模型配置: 选择你需要的模型提供商(如 Google Gemini、OpenAI 等),并粘贴你准备好的 API Key。如果不确定,可以先选择 Google 授权登录或选择默认配置。
  2. 渠道配置: 向导会询问你希望通过哪个聊天软件与 OpenClaw 交流(如 Telegram、WhatsApp)。对于首次在 Mac 上测试,建议先跳过,我们后续可以直接使用 Web 界面。
  3. 技能(Skills)与钩子(Hooks): 当问到 Install default skills? 时,强烈建议选 Yes(先按空格键选中,再按回车确认)。Hooks 也推荐安装,这会极大增强助理的功能性。

第四步:检查服务与启动 UI

配置完成后,我们来验证一下这只“龙虾”是否在正常工作。

  1. 运行健康检查:
    openclaw doctor
    

    这个命令会对你的系统环境和配置文件进行全面体检,并给出修复建议。

  2. 查看网关状态:
    openclaw status
    

    或者

    openclaw gateway status
    

    如果一切正常,你会看到 Gateway 服务正在运行(active)。

  3. 打开操作界面: 在确保 Gateway 启动的前提下,执行:
    openclaw dashboard
    

    该命令会自动生成一个包含临时登录令牌的 URL(通常是 http://127.0.0.1:18789/)并自动在浏览器中打开。

第五步:开始使用

在浏览器打开的 Dashboard 界面中,你可以直接与 AI 助理聊天了。试着让它帮你整理一下“下载”文件夹,或者让它总结一下你电脑里的某份项目文档。

小技巧: 如果你更喜欢终端环境,可以用 openclaw tui 命令启动全屏的文本交互界面,体验极客范儿的操作。

常见问题与解决

  • 问题:提示 openclaw: command not found 解决: 这通常是 npm 全局安装路径没加到系统 PATH 里。输入 npm prefix -g 查看路径,然后编辑你的 ~/.zshrc 文件,加入 export PATH="$(npm prefix -g)/bin:$PATH",重启终端即可。

  • 问题:安装时报错 sharp 模块错误 解决: macOS 有时会因 Homebrew 安装的 libvips 库冲突导致。尝试强制安装预编译二进制文件:

    SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g openclaw@latest
    

结语

OpenClaw的火爆,本质上反映了人们对 AI 落地的渴望——我们不仅想要一个能聊天的机器人,更想要一个能干活的数字员工。它像一条鲶鱼,搅动了硬件厂商、云厂商和开发者社区的神经。

通过今天的教程,你的 Mac 应该已经成功“养”上了一只龙虾。但这只是开始。OpenClaw 的真正魅力在于你可以教它新的技能,甚至让它自己编写代码来改进自己。未来已来,只是分布不均。现在,它就在你的终端里。

Happy prompting! 🦞

【AI】集成doubao-vision视觉api实现卡路里识别

【AI】集成doubao-vision视觉api实现卡路里识别

本文介绍了Android和IOS平台集成在线视觉AI的过程

这个功能实现很早了,在开源项目 PeachAssistant 里已经有体现了,利用CMP跨平台技术在Android和IOS平台均完成了功能的集成。现记录一下api的介绍和两个平台的执行流程。

豆包API介绍

首先在个人控制台的服务管理页面开通视觉识别api权限,获取 API_KEY ,baseURL为 https://ark.cn-beijing.volces.com/api/v3 。图片可以使用url或者文件文件base64编码上传两种方案。

如果你要传入的图片/视频在本地,你可以将这个其转化为 Base64 编码,然后提交给大模型。下面是一个简单的示例代码。

传入 Base64 编码格式时,请遵循以下规则

传入的是图片:
格式遵循data:image/<图片格式>;base64,<Base64编码>,其中,
图片格式:jpeg、png、gif等,支持的图片格式详细见图片格式说明。
Base64 编码:图片的 Base64 编码。

传入的是视频:
格式遵循data:video/<视频格式>;base64,<Base64编码>,其中,
视频格式:MP4、AVI等,支持的视频格式详细见视频格式说明。
Base64 编码:视频的 Base64 编码。

请求实例:

BASE64_IMAGE=$(base64 < path_to_your_image.jpeg) && curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json"  \
   -H "Authorization: Bearer $ARK_API_KEY"  \
   -d @- <<EOF
   {
    "model": "doubao-seed-1-6-251015",
    "messages": [
      {
        "role": "user",
        "content": [
            {
            "type": "image_url",
            "image_url": {
              "url": "data:image/jpeg;base64,$BASE64_IMAGE"
            },
            {
            "type": "text",
            "text": "图里有什么"
            }
        ]
      }
    ],
    "max_tokens": 300
    }
EOF

可以通过 detail 字段控制图片理解的精细度。

  • low:“低分辨率”模式,默认此模式,处理速度会提高,适合图片本身细节较少或者只需要模型理解图片大致信息或者对速度有要求的场景。此时 min_pixels 取值3136、max_pixels 取值1048576,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)将会等比例缩放至范围内。
  • high:“高分辨率”模式,这代表模型会理解图片更多的细节,但是处理图片速度会降低,适合需要模型理解图像细节,图像细节丰富,需要关注图片细节的场景。此时 min_pixels 取值3136、max_pixels 取值4014080,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)的图片将会等比例缩放至范围内。

例如:

curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer $ARK_API_KEY" \
   -d '{
    "model": "doubao-seed-1-6-251015",
    "messages": [
        {
            "role": "user",
            "content": [                
                {"type": "image_url","image_url": {"url":  "https://ark-project.tos-cn-beijing.volces.com/doc_image/ark_demo_img_1.png"},"detail": "high"},
                {"type": "text", "text": "支持输入图片的模型系列是哪个?"}
            ]
        }
    ],
    "max_tokens": 300
  }'

KMP公共网络请求

class DoubaoVisionRepository(private val ktorClient: KtorClient) {

    companion object {
        const val BASE_URL =
            "https://ark.cn-beijing.volces.com/api/v3"
        const val VISION_SYSTEM_PROMT =
            "下图是一张食物图片,请你计算每种食物的重量和卡路里,返回一个json,其中name为String,weight为Int,calorie为Int(单位千卡),json格式:\n" +
                    "{\n" +
                    "  \"foods\": [\n" +
                    "    {\n" +
                    "      \"name\": \"食物名称\",\n" +
                    "      \"weight\": \"食物重量\",\n" +
                    "      \"calorie\": \"食物卡路里\"\n" +
                    "    }\n" +
                    "  ]\n" +
                    "}"
        const val API_KEY = "xxxxxxxxxxxx"
        const val MODEL_NAME = "doubao-1-5-vision-pro-32k-250115"
    }

    suspend fun calCalorieByAI(imageType: String, imageBase64:String) = withContext(Dispatchers.IO) {
        ktorClient.client.post("${BASE_URL}/chat/completions") {
            // 配置请求头
            headers {
                append("Content-Type", "application/json")
                append("Authorization", "Bearer $API_KEY")
            }
            setBody(
                DoubaoVisionRequest(
                    model = MODEL_NAME,
                    messages = listOf(
                        DoubaoRequestMessage(
                            role = ChatRole.SYSTEM.roleDescription,
                            content = listOf(
                                DoubaoVisionContent(
                                    type = "text",
                                    text = VISION_SYSTEM_PROMT,
                                ),
                                DoubaoVisionContent(
                                    type = "image_url",
                                    image_url = ImageUrl(
                                        url = "data:image/$imageType;base64,$imageBase64"
                                    ),
                                )
                            )
                        ),
                    )
                )
            )
        }.body<DoubaoVisionResponse>()
    }
}

Android实现

权限申请

相册上传

运行截图:

实时拍照上传

IOS实现

权限申请

相册上传

实时拍照上传

【AI】多模态模型的多样化数据处理

【AI】多模态模型的多样化数据处理

本文介绍了文本模型之外的多模态AI模型如何处理数据的

经过前面若干篇的学习,我了解到LLM是如何处理输入文本,一轮一轮地进行前向推理,最后输出结果反馈的。

那多模态的AI模型,又是如何处理一帧一帧的图像,或者音频数据呢?现对这些不同于文本的数据处理进行一段学习总结。

简单来说,模型通过专门设计的 “编码器” 将不同类型的数据“翻译”成同一种“语言”——也就是 向量 。这个过程可以分为两大步:

  1. 独立编码(Independent Encoding):每种数据类型(图像、音频)都有一个专门的编码转换器,负责将其从原始格式转换成一个初步的向量序列。
  2. 对齐与融合(Alignment & Fusion):通过特殊的训练方法,让这些来自不同专家的向量在同一个“语义空间”里对齐,使得“小狗的图片”和“小狗的叫声”以及文字“小狗”的向量在空间中的位置非常接近。

向量嵌入模型

向量嵌入(Vector Embedding) 模型是当今许多AI应用的基石。

想象一下,你有一个巨大的图书馆,里面有成千上万本书。现在,你想找到所有和“科幻”相关的书。一个笨方法是逐一阅读每一本书的简介。这太慢了。

一个聪明的图书管理员(我们的AI模型)想出了一个好办法:他没有给书贴上“科幻”、“历史”这样的 简单标签 ,而是为每本书在图书馆里分配了一个 精确的三维坐标 (例如,坐标 [x, y, z])。

这个坐标的分配原则是:

  • 内容相似的书,在空间中的位置就非常接近。比如,《三体》和《银河帝国》的坐标可能非常靠近。
  • 内容无关的书,在空间中的位置就非常遥远。比如,《三体》和《莎士比亚戏剧集》的坐标会离得很远。
  • 坐标轴本身也代表了某种“意义”。也许x轴代表“虚构程度”,y轴代表“科技含量”,z轴代表“年代”。

这样一来,找书就变得非常简单:

  1. 你告诉管理员你要找“一部关于星际旅行和外星文明的小说”。
  2. 管理员将你的需求也转换成一个坐标。
  3. 然后,他在图书馆的这个三维空间里,找到离你的需求坐标 最近 的那些书。

在这个比喻里:

  • 书/你的需求:就是我们要处理的数据(单词、句子、图片、商品等)。
  • 坐标 [x, y, z]:就是向量嵌入 (Vector Embedding)。它是一个由数字组成的数组(向量),代表了原始数据在高维空间中的位置。
  • 整个三维空间:被称为嵌入空间 (Embedding Space)
  • 聪明的图书管理员:就是向量嵌入模型

向量嵌入可以将各种复杂、离散的数据(如文字、图片)转换成计算机可以理解和比较的、连续的、稠密的数字向量,并在这个过程中保留数据的“语义信息”。

为什么需要向量嵌入?

计算机不理解“苹果”这个词。它只懂数字。在AI出现之前,我们可能会用 One-Hot 编码(独热编码) 来表示单词。

假设我们的词典里只有5个词:[猫, 狗, 苹果, 香蕉, 橙子]。

  • 猫:[1, 0, 0, 0, 0]
  • 狗:[0, 1, 0, 0, 0]
  • 苹果:[0, 0, 1, 0, 0]

这种方法有两个 严重的缺陷

  1. 维度灾难:如果词典有10万个词,每个词的向量就有10万维,非常稀疏和浪费空间。
  2. 无法表达语义相似性:从数学上看,[1, 0, 0][0, 1, 0] 之间的距离,与 [1, 0, 0][0, 0, 1] 之间的距离是完全一样的。也就是说,模型无法知道“猫”和“狗”的关系比“猫”和“苹果”更近。所有词之间都是孤立的。

向量嵌入完美地解决了这两个问题。它使用一个更低维度(通常是几百到几千维)稠密向量 来表示数据,并且向量之间的距离和方向能够反映数据之间的语义关系。

向量嵌入模型的工作原理

模型是如何学会给每个单词或句子分配一个“有意义”的坐标的呢?答案是:通过在一个巨大的数据集上进行 “自监督学习”

核心原理可以用一句话概括:“一个词的意义,由它周围的词来定义”

我们以一个经典的词嵌入模型 Word2Vec 为例来解释这个过程。

训练过程(以 Word2Vec 的 Skip-gram 模式为例):

  1. 准备数据:获取海量文本,比如整个维基百科。

  2. 建立任务:我们给模型设定一个任务——根据一个中心词,预测它周围的词(上下文)
    • 例如,在句子 “一只可爱的正坐在垫子上” 中。
    • 中心词是 “猫”。
    • 上下文是 “一只”、“可爱的”、“正”、“坐在”。
  3. 模型初始化
    • 为词典里的每一个词,随机生成一个向量(比如300维)。此时,这些向量是毫无意义的。
  4. 开始训练(迭代学习)
    • 输入:我们把 “猫” 的随机向量输入到一个简单的神经网络中。
    • 预测:模型会根据这个输入向量,输出一个预测,表示它认为“猫”周围最可能出现哪些词。在训练初期,这个预测肯定是乱七八糟的。
    • 计算误差:我们将模型的预测结果与真实的上下文(“一只”、“可爱的”等)进行比较,计算出一个损失(Loss)误差(Error)。误差越大,说明模型预测得越差。
    • 反向传播与更新:算法会根据这个误差,微调(更新)神经网络的权重,尤其是“猫”的输入向量。调整的原则是:让“猫”的向量变得“更擅长”预测出它周围的词
    • 重复:对文本库里的每一个词都重复这个过程亿万次。
  5. 最终结果
    • 训练结束后,词典里每个词的向量都经过了无数次的微调。
    • 因为“猫”和“狗”经常出现在相似的上下文中(比如“可爱的__”、“喂养__”、“宠物__”),为了能同时预测好这些上下文,模型会“自发地”将“猫”和“狗”的向量调整到嵌入空间中非常相近的位置。
    • 而“猫”和“苹果”的上下文几乎完全不同,所以它们的向量在空间中就会相距很远。

最终,我们扔掉用于预测的神经网络,只保留训练好的、包含所有词及其对应向量的那个查找表。这个表就是我们的词嵌入模型

嵌入的奇妙特性:

训练好的嵌入向量甚至可以捕捉到更复杂的关系,最经典的例子是: \[\text{vector('King')} - \text{vector('Man')} + \text{vector('Woman')} \approx \text{vector('Queen')}\]

这表明,嵌入空间中的向量方向也蕴含了语义,例如“性别”或“皇室”等抽象概念。

著名/主流的嵌入模型

  1. Word2Vec (Google): 开创性的词嵌入模型,简单高效。它包含两种模式:Skip-gram(根据中心词预测上下文)和 CBOW(根据上下文预测中心词)。
  2. GloVe (Stanford): 另一种经典的词嵌入模型,它利用全局词-词共现矩阵来生成嵌入,考虑了全局统计信息。
  3. BERT (Google) & Transformer-based Models: 这是现代嵌入模型的主流。
    • 关键区别:Word2Vec 为每个词生成的向量是静态的、唯一的。但在现实中,词的意义随语境而变。例如,“bank”在 “river bank”(河岸)和 “investment bank”(投资银行)中的意思完全不同。
    • BERT这类模型是上下文相关的(Contextual)。它在生成一个词的嵌入时,会同时考虑整个句子的信息。因此,同一个词在不同句子中会得到不同的嵌入向量,这极大地提升了表示的准确性。
  4. OpenAI Embeddings (如 text-embedding-ada-002): 目前非常流行和强大的通用文本嵌入模型,广泛用于各种AI应用。
  5. CLIP (OpenAI): 一种强大的多模态嵌入模型。它可以为一张图片和一个描述该图片的句子生成非常相似的向量。这使得通过文本搜索图片成为可能。

应用场景

向量嵌入是许多现代AI系统的“引擎”,它的应用无处不在:

  1. 语义搜索/向量搜索
    • 传统的关键字搜索只能匹配字面内容。而向量搜索可以理解查询的“意图”。你搜索“夏天穿的透气鞋子”,它能返回商品名里没有这些词但符合描述的“网面运动凉鞋”。
    • 这是目前 RAG (Retrieval-Augmented Generation,检索增强生成) 技术的核心,大语言模型通过向量搜索找到相关知识库内容,再进行回答,以减少幻觉。
  2. 推荐系统
    • 将用户和商品都嵌入到同一个向量空间中。一个用户的向量,会和他可能喜欢的商品的向量非常接近。通过计算向量相似度,可以为用户推荐他可能感兴趣的商品、电影或音乐。
  3. 文本分类与聚类
    • 将文本转换成向量后,可以轻松地使用机器学习算法进行情感分析(正面/负面评论)、新闻主题分类等。相似的文本向量会自然地“聚”在一起。
  4. 问答系统和聊天机器人
    • 将用户的问题和知识库中的“问题-答案”对都转换成向量。通过找到与用户问题向量最相似的问题向量,来返回对应的答案。
  5. 图像搜索
    • 以图搜图(找到相似图片)或以文搜图(输入“一只猫在草地上”,返回对应的图片)。

图像数据

第一步类似于文本模型,首先要理解输入内容物是什么东西。在将图片信息与其他模态(如文本)进行融合之前,模型需要将原始像素数据转换为有意义的、可供计算的向量表示,这称为特征提取。

数据特征提取

一般通过 卷积神经网络 (CNN),尤其是像 ResNet、VGG 或 ViT (Vision Transformer) 这样的模型架构。

  • CNN 的作用: CNN 通过多层卷积操作,从图片中自动学习和提取层级特征。浅层提取边缘、纹理等基础特征;深层提取鼻子、眼睛、汽车等高层语义特征。
  • Vision Transformer (ViT) 的作用: ViT 不使用卷积,而是将图像分割成许多小块,然后使用 Transformer 的自注意力机制来捕捉这些小块之间的关系,这与处理文本的方式相似,有助于模态间的对齐。

提取器最终输出一个图像嵌入向量,它是一个高维向量,浓缩了整张图片或图片中关键区域的语义信息。

一、 图像数据的向量化

原始的图像数据是一个由像素值(RGB)构成的三维矩阵(宽 x 高 x 通道)。使用当前最主流的 Vision Transformer 架构来处理它时,这个流程是怎样的呢?

ViT过程拆解:

  1. 图像分块
    • 模型不会一次性看整个图像的几百万个像素,这计算量太大了。相反,它会像切拼图一样,将原始图像(例如 224x224 像素)切割成一系列固定大小的小方块(Patches),比如每个方块是 16x16 像素。
    • 这样,一张 224x224 的图像就变成了一个由 (224/16) * (224/16) = 14 * 14 = 196 个小方块组成的序列
  2. 展平与线性投射
    • 将每个 16x16x3 (3是RGB通道) 的小方块展平,变成一个长向量。
    • 然后,通过一个可学习的线性投射层(Linear Projection Layer),将这个长向量映射(降维或升维)到一个固定的维度,比如768维。
    • 现在,我们就得到了一个由196个768维向量组成的序列。这在结构上就和经过词嵌入的句子(由多个词向量组成的序列)非常相似了!
  3. 加入位置编码
    • 和文本一样,这些图像块的相对位置非常重要(“耳朵”在“头”的上面)。因此,模型会为每个图像块向量加入一个位置编码向量,来告诉模型每个小块的原始位置信息。
  4. 通过 Transformer 编码器
    • 将这个带有位置信息的向量序列输入到一个标准的 Transformer 编码器中。
    • 编码器内部的 自注意力机制(Self-Attention) 会让每个图像块去“关注”其他的图像块,从而理解它们之间的关系和全局结构。例如,一个代表“车轮”的图像块会和代表“车身”的图像块建立强关联。
    • 经过多层Transformer Block的处理后,模型就得到了对整个图像内容和结构的深度理解。
  5. 输出最终向量
    • 通常会借鉴BERT中的 [CLS] 思想,在图像块序列的最前面添加一个特殊的 [CLASS] 向量。在经过Transformer编码器后,这个 [CLASS] 向量对应的最终输出向量,就被认为是代表整个图像语义的聚合向量。

最终,一张复杂的图像就被转换成了一个单一的、高维的、包含丰富语义的向量(例如768维)。

音频数据

原始的音频数据是 一维的波形信号 ,它记录了随时间变化的振幅。直接处理这个长序列非常困难。因此,标准做法是先将其转换成一种“像图像一样”的二维表示。

预处理:波形转频谱图

音频的核心信息在于不同频率的声音随时间如何变化。通过 短时傅里叶变换(STFT) 将原始的一维波形转换成一个 频谱图(Spectrogram)

这个频谱图是一个二维图像:

  • X轴 代表 时间
  • Y轴 代表 频率
  • 颜色/亮度 代表该频率在该时间的 能量(音量) 通常会使用梅尔频谱图(Mel-Spectrogram),因为它更贴近人耳对频率的感知方式。通过这个转换之后,音频数据就变成了一张“图像”!

使用类似图像的处理方法

一旦我们有了频谱图这个二维表示,接下来的处理就和上面图像处理的流程非常相似了。模型(例如 Audio Spectrogram Transformer, AST)也会将这张频谱图切割成一系列的小方块(Patches)。同样地,对这些方块进行 线性投射 、加入 位置编码 ,然后将它们组成的序列送入一个 Transformer 编码器 。Transformer的自注意力机制能够捕捉音频序列中长距离的依赖关系,类似于理解一句话中前后词语的语境。

输出最终向量

与ViT类似,经过Transformer编码器处理后,模型会输出一个代表整个音频片段语义的聚合向量。这个向量捕捉了音频中的内容,比如是人声(说了什么)、音乐(什么风格)还是环境音(狗叫、汽车声)。

图像和音频的模态对齐融合

现在我们有了 图像向量、音频向量和文本向量 。但此时它们还处在各自的世界维度里,无法直接比较。让它们统一到同一个语义空间的关键技术是 对比学习 。这是多模态理解的核心。模型需要学会这些音频向量和文本/视觉向量之间的关系。

CLIP (Contrastive Language-Image Pre-training) 模型为例,它就是专门用来对齐图像和文本的:

  • 数据输入 :收集数亿个 (图像, 文本描述) 的配对数据。
  • 训练目标 :在训练过程中,模型会看到大量的“音频-文本” 键值对,例如: “一段狗叫声” 和文本 “一只狗在叫” 。将一个图像和它 正确匹配 的文本描述分别通过各自的编码器,得到 image_vectortext_vector。模型的目标是 拉近(Maximize Similarity) 这对正样本(matched pair)向量的相似度(例如,余弦相似度)。同时,对于一个图像, batch里的所有其他文本描述都是负样本(unmatched pairs)。模型的目标是 推远(Minimize Similarity) 这个图像向量和所有这些错误文本向量的相似度。

最终,“狗叫”的音频向量和“狗叫”的文本向量在语义空间中的位置会非常接近。通过在这种“连连看”式的任务上进行大规模训练,图像编码器和文本编码器会“被迫”学会一种共识。它们会自发地将 语义上相似 的概念映射到向量空间中的 邻近区域 ,无论这个概念是来自图片还是文字。

输入一张 “金毛犬在草地上玩耍” 的图片所生成的向量,会和句子 “a golden retriever playing on the grass” 生成的向量在空间上非常非常接近。

这个对齐过程同样适用于音频。通过训练 (音频, 文本描述) 配对数据,音频编码器也能学会将“狗叫声”的音频片段映射到和文字“dog barking”相近的空间位置。

Pagination