阅读视图

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

TypeScript 类型体操:如何为 SDK 编写优雅的类型定义

背景

作为一款 SDK,提供完善的 TypeScript 类型定义(.d.ts)是对用户最基本的尊重。 AutoForm 的配置项非常复杂,且存在很多联动关系。如何用 TS 准确描述这些关系?

今天带大家做一套类型体操。

挑战一:互斥属性

如果配置了 mode: 'auto',则 interval 必填;如果 mode: 'manual',则 interval 不可填。

错误写法:

interface Config {
  mode: 'auto' | 'manual';
  interval?: number;
}

正确写法(Discriminated Unions):

type AutoConfig = {
  mode: 'auto';
  interval: number;
};

type ManualConfig = {
  mode: 'manual';
  interval?: never; // 关键:禁止出现
};

type Config = AutoConfig | ManualConfig;

挑战二:事件回调的类型推导

我们希望用户在监听事件时,能自动推导出 event 对象的类型。

sdk.on('success', (e) => {
  console.log(e.data); // e 应该是 SuccessEvent
});

sdk.on('error', (e) => {
  console.log(e.message); // e 应该是 ErrorEvent
});

实现:

type EventMap = {
  success: { any };
  error: { message: string; code: number };
};

class SDK {
  on<K extends keyof EventMap>(
    type: K, 
    handler: (event: EventMap[K]) => void
  ) {
    // ...
  }
}

挑战三:深度 Partial

用户配置时,通常只需要覆盖默认配置的一部分。我们需要一个递归的 Partial

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

总结

TypeScript 不仅仅是类型检查工具,更是最好的文档。写好类型定义,能让用户在使用 SDK 时获得极致的智能提示体验,减少查阅文档的时间。

👉 官网地址:51bpms.com

Vite 插件开发实战:如何自动注入 SDK 脚本

需求背景

AutoForm SDK 开发完成后,我们需要提供给客户集成。 对于使用 Vite 的客户,如果能提供一个插件,让他们在 vite.config.ts 里配置一下就能用,体验会好很多。

本文将手把手教你开发一个 Vite 插件,实现 SDK 脚本的自动注入。

插件结构

Vite 插件本质上是一个返回特定对象的函数。

// vite-plugin-autoform.ts
export default function autoFormPlugin(options) {
  return {
    name: 'vite-plugin-autoform',
    // 插件钩子
    transformIndexHtml(html) {
      // ...
    }
  };
}

核心逻辑:transformIndexHtml

我们需要在 index.html<body> 结束标签前插入 SDK 的 <script> 标签。

transformIndexHtml(html) {
  const scriptTag = `
    <script>
      window.AIFormConfig = ${JSON.stringify(options)};
    </script>
    <script src="https://cdn.autoform.com/sdk.js" async></script>
  `;
  
  return html.replace('</body>', `${scriptTag}</body>`);
}

进阶:开发环境与生产环境区分

在开发环境(npm run dev)下,我们可能希望注入本地的 SDK 脚本,方便调试。

configResolved(config) {
  isDev = config.command === 'serve';
},

transformIndexHtml(html) {
  const src = isDev 
    ? 'http://localhost:3000/sdk.js' 
    : 'https://cdn.autoform.com/sdk.js';
    
  // ...
}

发布到 NPM

  1. 创建 package.json
  2. 配置 maintypes
  3. npm publish

现在,用户只需要:

npm install vite-plugin-autoform

然后在 vite.config.ts 中:

import autoForm from 'vite-plugin-autoform';

export default {
  plugins: [autoForm({ token: 'xxx' })]
};

总结

Vite 插件开发非常简单,但能极大地提升用户体验。对于 SDK 开发者来说,提供配套的构建工具插件是必不可少的。

👉 官网地址:51bpms.com

别再用 ID 定位了!教你用"语义指纹"实现 99% 的元素定位成功率

痛点

做过自动化测试或爬虫的同学一定遇到过这种情况: 昨天写的脚本 document.querySelector('#login-btn') 还能跑,今天网站更新了,ID 变成了 #login-btn-123,脚本直接挂掉。

在开发 AutoForm 智能表单填充 SDK 时,我们面临同样的挑战:如何让 SDK 在页面结构变化后,依然能找到那个"用户名输入框"?

答案是:语义指纹(Semantic Fingerprinting)

什么是语义指纹?

人类找元素不是靠 ID,而是靠"特征"。 当你看到一个输入框,旁边写着"用户名",里面提示"请输入手机号",你就知道它是干嘛的。

我们将这些特征提取出来,生成一个唯一的哈希值,这就是"语义指纹"。

算法实现

1. 特征提取

我们提取以下维度的特征:

  • Label 文本:这是最强的语义特征。
  • Placeholder:提示文案。
  • Name 属性:通常包含字段含义(如 username, email)。
  • Input Type:输入类型(text, password, checkbox)。
  • 前驱/后继文本:DOM 树中相邻的文本节点。
function extractFeatures(element) {
  return {
    tag: element.tagName,
    type: element.type,
    name: element.name,
    label: findLabel(element), // 关联的 label 文本
    placeholder: element.placeholder,
    surroundingText: getSurroundingText(element)
  };
}

2. 指纹生成

将特征拼接成字符串,然后计算 Hash。

import { md5 } from 'hash-wasm';

const fingerprint = await md5(JSON.stringify(features));

3. 模糊匹配

当页面更新后,新元素的指纹可能与旧指纹不完全一致(比如 placeholder 变了)。这时我们计算相似度(Similarity Score)

我们使用 Levenshtein Distance(编辑距离) 算法来比较两个特征对象的相似度。如果相似度 > 80%,我们就认为找到了目标。

效果

引入语义指纹后,AutoForm 的定位抗干扰能力大幅提升:

  • ID 变化:完全免疫。
  • DOM 结构微调:完全免疫。
  • 文案微调:只要核心语义没变(如"用户名"变成"请输入用户名"),依然能识别。

总结

在 AI 时代,基于规则的硬编码(Hard-coding)已经过时了。基于特征的模糊匹配才是未来的方向。


思考:如果页面上有两个完全一样的输入框(如两个"手机号"),该如何区分?欢迎评论区交流!

👉 官网地址:51bpms.com

探索关于Spec 和Skills 的一些实战运用-Kiro篇

前两天刷掘金,看到一篇名为: 2025 Vibe Coding 元年:AI 编程的技术突破全景 的文章,这篇文章不仅详细剖析了近来编程领域可能的发展趋势,还意外地让我接触到了两个新的概念:Spec(规范)和Skills(技能)。在AI飞速发展的今天,这两个概念的引入显得尤为重要,它们不仅预示着编程规范的进一步标准化,也为技能提升提供了全新的路径。为了进一步理解这两个概念的实际应用,我动手进行了实践操作,下面就是我对这次探索的总结与思考。

或许我们可以实现:赋能 20%的的重要环节,提升整体 80%的效能???

一、你在用什么编辑器?

我的AI编辑器在今年一年内就换了三个,最开始是VScode, 然后是 Trae,现在使用的是Kiro,这一个月使用下来,还是蛮顺滑的,推荐 👍。

最开始是然.png

作为claude模型的金主,亚马逊的 kiro拥有claude 的最新模型:

有资料显示:截至2026年1月,亚马逊已向Anthropic(开发 Claude 人工智能模型的公司)累计投资80亿美元(2023年9月首投12.5亿,2024年3月追加27.5亿,2024年11月再投40亿)

截屏2026-02-04 16.49.07.png

下面的示例都是基于这个Kiro 编辑器,当然其他编辑器也有类似的功能,就需要自行探索了,工具本身就是服务于我们的工作,使用什么工具更顺手就用哪个喽!


二、先来了解几个常用的概念

SpecSkills 的关系

位置图片左.png

1. Spec‌标准性能评估组织,负责解释: 做什么事?

核心理念‌:采用“先计划,后执行”的流程,要求AI在编写代码前,先将任务理解、技术方案和详细拆解形成结构化文档(Doc和Tasks),经人工确认后再生成代码。 ‌

主要作用‌:将传统“黑盒直出”的编码模式变为“白盒化”阶段流程,通过引入“需确认”的缓冲阶段,大幅减少因理解偏差导致的无效工作和返工,提升代码生成的准确性。 ‌

适用场景‌:适用于从0到1的开发、技术方案原型产出、团队内部跨角色(如产品、前后端)沟通协作等场景。

就像项目需求文档、任务清单,大白话讲:就是你给我一个文件,告诉我这个项目怎么做

20260205-105320.jpeg

2. Skills 中文解释“技能”,就是教我"怎么做事"的规则手册。

就像我每次和AI对话时,总有一些特定的规则前提,不同的规则对应不同的prompt,根据关键词来触发我使用哪个内置的prompt,也可以设置每次对话都引入,比如下图:

就像你教一.png

3. 两者的关系

类比 1: 做菜 🍳

类比的步骤.png

关系:用烹饪技巧(Skills)来完成菜谱(Spec) 一个技巧可以用在很多菜谱上,一个菜谱需要用到多个技巧

类比 2: 盖房子 🏗️

类比的步骤 (1).png

关系: 按照施工规范(Skills)来实现建筑图纸(Spec) 规范是通用的,可以盖很多房子 图纸是具体的,只针对这一栋房子


4. 实际的工作流程-理论

假设场景:我们要开发字母拼写游戏

1. 你有一个 Spec(需求文档)📋 "创建字母拼写游戏"

  • 需要游戏组件
  • 需要游戏接口
  • 需要状态管理

2. 我使用 Skills(规则)来实现

创建组件时 → 应用 "React 组件生成 Skill"

✅ 按规范创建文件结构

✅ 按模板生成代码

✅ 遵循命名规范

添加接口时 → 应用 "API 接口生成 Skill"

✅ 检查是否重复

✅ 添加类型定义

✅ 添加完整注释

完成后 → 应用 "代码检查 Skill"

✅ 检查逻辑问题

✅ 检查性能问题

✅ 检查是否有重复代码

3. 完成 Spec✅ 字母拼写游戏开发完成 📊 对比表格

维度 Skills(技能) Spec(规格)
是什么 做事的方法 要做的事情
作用 教我怎么做 告诉我做什么
范围 通用的 具体的
时效 长期有效 一次性的
数量 少量(3-10个) 很多(每个功能一个)
位置 .kiro/steering/ .kiro/specs/
例子 "组件创建规范" "创建登录页面"
复用 可以复用到其他项目 只针对当前功能

三、下面就让我们用实际的AI编辑器来试试上面能力-实践:

在上面我们讨论了他们之间的关系,并使用示例图的形式展现出来,下面就是具体的实践示例。

首先是-省流最小示例:

.kiro/steering/
  ├── react-component-generator.md  ← "怎么创建组件"
  ├── api-service-generator.md      ← "怎么添加接口"
  └── code-review.md                ← "怎么检查代码"
Spec 的例子(具体需求)
.kiro/specs/
  └── letter-spelling-game/
      └── requirements.md           ← "做一个字母拼写游戏"   

接下来是使用Kiro的具体操作演示步骤:

1. 先来看 Spec

(1)发送指令前,先选择Spec模式:

截屏2026-02-04 17.08.35.png

(2)提出我的需求

prompt: 我想创建一个课堂互动小游戏,根据字母拼单词,帮我实现。

截屏2026-02-04 17.23.49.png

(3)生成具体的需求文档

提交提示词,生成具体的需求文档:

截屏2026-02-04 17.25.58.png

生成完成:

截屏2026-02-04 17.27.14.png

下面的文档我只截取了头部和尾部的部分,大家可以自己动手尝试一下:

# 需求文档

## 简介

课堂拼写游戏是一个教育类互动应用,旨在帮助学生通过游戏化的方式学习和练习英语单词拼写。系统会提供一组打乱顺序的字母,学生需要将这些字母重新排列组成正确的单词。

## 术语表

- **游戏系统(Game_System)**: 管理游戏流程、题目生成和答案验证的核心系统
- **题目(Question)**: 包含目标单词和打乱字母的游戏问题
- **单词库(Word_Bank)**: 存储可用于游戏的单词集合
- **学生(Student)**: 参与游戏的用户
- **回合(Round)**: 一次完整的答题过程,从显示题目到提交答案
- **得分系统(Scoring_System)**: 计算和记录学生表现的系统

## 需求

### 需求 1:题目生成

**用户故事:** 作为教师,我希望系统能够自动生成拼写题目,以便学生进行练习。

#### 验收标准

1. 当游戏开始时,游戏系统应当从单词库中随机选择一个单词
2. 当选择单词后,游戏系统应当将单词的字母打乱顺序
3. 当打乱字母时,游戏系统应当确保打乱后的字母顺序与原单词不同
4. 当生成题目时,游戏系统应当同时保存原始单词作为正确答案

### 需求 2:答案提交与验证

**用户故事:** 作为学生,我希望能够提交我的答案并立即知道是否正确,以便获得即时反馈。

#### 验收标准

1. 当学生输入答案时,游戏系统应当接受字母输入
2. 当学生提交答案时,游戏系统应当验证答案是否与正确单词完全匹配
3. 如果答案正确,那么游戏系统应当显示成功提示并增加得分
4. 如果答案错误,那么游戏系统应当显示错误提示并允许重试
5. 当验证答案时,游戏系统应当忽略大小写差异
..................
..................
..................
### 需求 12:响应式设计

**用户故事:** 作为教师,我想要游戏在不同设备上都能正常显示,以便在各种教学环境中使用。

#### 验收标准

1. 当游戏在大屏幕显示时(1920x1080或更高),游戏系统应当优化布局以充分利用屏幕空间
2. 当游戏在平板设备显示时,游戏系统应当调整字母按钮大小和间距以适应触摸操作
3. 当游戏在小屏幕设备显示时(手机),游戏系统应当采用垂直布局并调整字体大小
4. 当屏幕方向改变时,游戏系统应当在500毫秒内重新布局界面元素
5. 当游戏系统检测到触摸设备时,游戏系统应当增大可点击区域至少44x44像素以符合可访问性标准

(4)根据自己的需求进行微调

现在我们已经有了一个完整的需求文档以及具体的生成规则和验收流程,我们需要根据自己的具体需求进行调试,假设你还有其他的需求,也可以再添加到文档中,目的是为后续生成code 提供一个标准和依据。

然后我们就看到有一个这个 "Move to design phase" 意思就是进入设计阶段:

b8f72f95-4eae-43ec-831d-2ed632db2a78.png

(5)Move to design phase 进入设计阶段

现在它帮我生成了一个项目设计文档:

截屏2026-02-04 17.38.11.png

详细内容如下,当然在此步骤你依然可以对文档进行修改:

# 设计文档

![截屏2026-02-04 17.43.01.png](https://p9-juejin-sign.byteimg.com/tos-cn-i-k3u1fbpfcp/cc90dcbae9db4d05aaaad4bae399b9cf~tplv-k3u1fbpfcp-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg546W5pyI5pm056m6:q75.awebp?rk3s=f64ab15b&x-expires=1771226286&x-signature=9oE41Kqh%2BOwq0P%2BVKa5jwkpZ1dU%3D)
## 概述

课堂拼写游戏是一个基于 Web 的互动教育应用,采用客户端-服务器架构。系统的核心功能包括:随机生成拼写题目、验证学生答案、管理单词库、跟踪学习进度。设计强调简洁性、可扩展性和教育效果。

## 架构

系统采用三层架构:

1. **表示层(Presentation Layer)**: 负责用户界面渲染和用户交互
2. **业务逻辑层(Business Logic Layer)**: 处理游戏逻辑、答案验证、得分计算
3. **数据访问层(Data Access Layer)**: 管理单词库和用户数据的持久化

### 架构图


┌─────────────────────────────────────┐
│      表示层 (UI Components)          │
│  - 游戏界面                          │
│  - 单词库管理界面                     │
│  - 得分显示                          │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│      业务逻辑层 (Game Engine)        │
│  - QuestionGenerator                │
│  - AnswerValidator                  │
│  - ScoringSystem                    │
│  - HintProvider                     │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│      数据访问层 (Data Layer)         │
│  - WordBankRepository               │
│  - ScoreRepository                  │
│  - LocalStorage / Database          │
└─────────────────────────────────────┘


## 组件和接口

### 1. QuestionGenerator(题目生成器)

负责从单词库中选择单词并生成打乱字母的题目。

**接口:**

```typescript
interface QuestionGenerator {
  // 根据难度级别生成新题目
  generateQuestion(difficulty: DifficultyLevel): Question

  // 验证生成的题目是否有效
  validateQuestion(question: Question): boolean
}


**核心方法:**

- `selectWord(difficulty: DifficultyLevel): string` - 根据难度从单词库随机选择单词
- `shuffleLetters(word: string): string` - 打乱单词字母顺序
- `ensureDifferent(original: string, shuffled: string): string` - 确保打乱后与原单词不同

### 2. AnswerValidator(答案验证器)

验证学生提交的答案是否正确。

**接口:**

```typescript
interface AnswerValidator {
  // 验证答案是否正确(忽略大小写)
  validate(userAnswer: string, correctAnswer: string): boolean

  // 验证输入是否只包含字母
  validateInput(input: string): boolean
}
 

**核心方法:**

- `normalize(text: string): string` - 标准化文本(转小写、去空格)
- `isAlphabetic(text: string): boolean` - 检查是否只包含字母

### 3. ScoringSystem(得分系统)

计算和管理学生得分。

**接口:**

```typescript
interface ScoringSystem {
  // 计算答题得分
  calculateScore(isCorrect: boolean, hintsUsed: number, timeSpent: number): number

  // 更新总分
  updateTotalScore(score: number): void

  // 获取当前统计信息
  getStatistics(): GameStatistics
}
 

**得分规则:**

- 基础分:正确答案 10 分
- 提示惩罚:每使用一次提示减 2 分
- 时间奖励:快速答题(< 10 秒)额外 +3 分
- 最低分:每题最低 1 分

..........

(6)生成测试任务列表

截屏2026-02-04 17.44.28.png

现在有两个选项,一个是保持可选任务(更快的MVP),我选择的是: "将所有任务设为必须"

(7)任务完成

此时我们需要的需求文档,设计文档和测试文档都已经全了,里面告诉我们接下来项目具体细节是啥,那么后面我们就可以拿着这些文档喂给我们的项目,让他去设计页面了~

截屏2026-02-04 17.48.56.png


总结:

以上就是Spec的整个流程,以我的使用感受来看,这个Spec比较适用于从头开始的新项目,或者是从头开始一个完整的新需求,如果你是只是修修补补,还是建议使用Vibe模式,更轻便,更简洁。


2. 接下来是Skills

(1)手动创建定制化于自己项目的skill

我让Kiro帮我生成一个关于接口API接入的编写规范

截屏2026-02-06 15.24.26.png

截屏2026-02-06 15.24.46.png

这就是根据我当前项目全面扫描后,根据以后的规则生成的一个接偶编写规范

截屏2026-02-06 15.27.45.png

如果你有自己的规则,可以添加新的规则到这个文档中:

如下,我需要添加关于重复接口的检查

截屏2026-02-06 15.35.56.png

更新后的结果:

截屏2026-02-06 15.36.58.png


发起思考???

除了添加AI接口规范文档,我们想想平常自己哪方面的工作比较耗费时间,并且大部分是有一定规则,我们就可以通过skill的方式,一次性让AI了解我们的复杂的诉求,比如:“代码优化”、“创建新组件” “原型还原” 都可以是一套完整的规则,且也可以随着使用来完善你的规则,渐进明细,让他成为一个你靠谱的工作伙伴。

除了可以自己创建,现在市场上也有很多开源的Skills,可以供大家选择:

鱼皮大佬的AI导航网站的推荐 Skills

这 6 个超神的 SKills,在 GitHub 上杀疯了

除了工作需要,例如我们需要一个生成旅游攻略,我的要求如下:

我想生成一个旅游攻略的skill,根据我的输入城市,推荐该城市或者这个城市周边的5个景点,景点可以是: 古建筑、 博物馆、 非物质文化遗产 、遗址 、 著名的4A景点 或者是 老街 、 民俗、演出、大型文艺园区(例如:吴桥杂技大世界)、大型的情景剧演出 以及和人文相关的等 , 给出景点附近的华住集团的酒店参考,给3个,给出景点和景点之间的距离,单位km,  我默认是从北京自驾出发,如果行程过远(超过600km) ,提醒我是否要更换交通工具,如果筛选出的景点,在当前的时间季节不是最好的季节,或者可能不开放,也请提示我是否更换景点,或者更换城市,

生成后,我可以根据自己的想法,再进行修改:

帮我更新这个skill ,如果我提出去 某个城市旅游,请提问我旅游的天数,以及每天推荐一款该城市的著名小吃,或者 这个城市的主打的小吃,我刚刚添加了一些常住的酒店,帮我一起更新

下面是我生成的skill.md文件:

---
inclusion: manual
---

# 🗺️ 人文旅游攻略生成器

**触发条件:提示词包含"旅游"、"攻略"、"景点"、"出行"、"自驾"等关键词,或直接输入城市名称**

## 基本设定

- 出发地:北京(默认自驾)
- 当前日期:根据实际日期判断季节
- 旅游天数:**需先询问用户**
- 景点数量:根据天数动态调整(每天1-2个景点)
- 酒店品牌:华住集团旗下(全季、汉庭、桔子、水晶、CitiGO、漫心、花间堂、海友、禧玥等)
- 每个景点推荐3个附近华住酒店
- 每日小吃:每天推荐1款当地著名小吃或主打美食

## 景点筛选范围

优先推荐以下类型的人文景点(非自然风光类):

1. **古建筑**:古城、古镇、寺庙、祠堂、城楼、古塔等
2. **博物馆**:省级/市级博物馆、专题博物馆、纪念馆等
3. **非物质文化遗产**:传统技艺体验地、非遗展示馆等
4. **历史遗址**:古战场、古都遗址、考古遗址公园等
5. **4A/5A景区**:以人文类为主的高等级景区
6. **老街/古街**:历史文化街区、传统商业街等
7. **民俗体验**:民俗村、传统集市、地方特色活动等
8. **演出/剧场**:大型情景剧演出、实景演出(如《又见平遥》《印象系列》)
9. **大型文艺园区**:吴桥杂技大世界、横店影视城等主题文化园区
10. **其他人文景点**:名人故居、革命纪念地、宗教文化场所等

## 输出格式

```markdown
# 🗺️ [城市名] 人文旅游攻略

📍 出发地:北京
🚗 交通方式:自驾(或建议更换的交通方式)
📅 出行日期:[当前日期]
🌤️ 当地季节/天气提示:[季节信息]

---

## ⚠️ 出行提醒(如有)

> [距离超过600km的提醒]
> [季节/开放时间提醒]

---

## 📋 景点总览

| 序号 | 景点名称 | 类型 | 距北京(km) | 景点间距(km) |
|------|---------|------|-----------|-------------|
| 1 | xxx | xxx | xxx | - |
| 2 | xxx | xxx | xxx | 距景点1: xx |
| 3 | xxx | xxx | xxx | 距景点2: xx |
| 4 | xxx | xxx | xxx | 距景点3: xx |
| 5 | xxx | xxx | xxx | 距景点4: xx |

---

## 🏛️ 景点详情

### 1. [景点名称]
- **类型**:古建筑 / 博物馆 / 非遗 / 遗址 / 4A景区 / 老街 / 民俗 / 演出 / 文艺园区
- **地址**:xxx
- **门票**:xxx元(或免费)
- **开放时间**:xx:xx - xx:xx
- **推荐游览时长**:x小时
- **亮点**:一句话描述核心看点
- **季节提示**:⚠️(如当前季节不适宜或可能不开放,给出提示)

**🏨 附近华住酒店推荐:**

| 酒店名称 | 品牌 | 距景点(km) | 参考价格 |
|---------|------|-----------|---------|
| xxx | 全季/汉庭/桔子等 | x.x | ¥xxx/晚 |
| xxx | xxx | x.x | ¥xxx/晚 |
| xxx | xxx | x.x | ¥xxx/晚 |

---

(景点2-5 同上格式)

---

## 🛣️ 推荐行程路线

**Day 1**:北京出发  景点1  景点2
🏨 住宿:xxx酒店(品牌 | ¥xxx/晚)
🍜 今日小吃:**[小吃名称]**  [一句话介绍,如"西安回民街必吃,皮薄馅大汤汁鲜"] | 📍 推荐店铺:[店名]

**Day 2**:景点3  景点4
🏨 住宿:xxx酒店(品牌 | ¥xxx/晚)
🍜 今日小吃:**[小吃名称]**  [一句话介绍] | 📍 推荐店铺:[店名]

**Day 3**:景点5  返程
🍜 今日小吃:**[小吃名称]**  [一句话介绍] | 📍 推荐店铺:[店名]
...........

现在我们来尝试一下这个旅游的skill:

截屏2026-02-09 14.53.33.png

截屏2026-02-09 14.53.45.png

截屏2026-02-09 14.53.57.png

确实跟我自己搜索的差不多,除了清真寺和纪晓岚文化园我没有计划在内,其他几个还是值得一去的!内容确实包含了我需要的具体的景点信息,酒店信息,美食推荐以及景点和景点的相差公里等方面,还算符合我的要求,后面可以再根据自己的喜好调整即可,基本上是直接可用的状态。


回归主题,接下来我们看一下在工作中怎么配置?

你想让这个skill在什么时候加载?

1. 始终加载(默认,每次对话自动加载,不需要关键词)

  inclusion: always

什么时候生效?

  • 你在聊天框里问 AI 任何问题
  • 你让 AI 帮你写代码、改代码
  • 你让 AI 解释某段代码
  • Hook 触发后 AI 开始工作(因为 Hook 本质也是一次对话)
  • 你用 SpecAI 帮你做功能设计和实现

换句话说,任何你和 AI 之间的交互,AI 都会带着这些规则。它不是一个检查动作,而是 AI 的"常识大脑🧠"。


2. 匹配文件时加载

inclusion: fileMatch
fileMatchPattern: "src/services/**/*.ts"

什么时候生效?

只有当对话中涉及到匹配的文件时,AI 才能看到。比如你可以设置只有读取 services/**/*.ts 文件时才注入 xxxxx 规则


3. 手动引用时加载(聊天中用 # 引用)

inclusion: manual

什么时候生效?

你在聊天框里用 # 手动引用时才注入


那么此时又有一个问题:如果我是自己手动修改的bug,并没有使用AI,但我又想让他帮我检查一下,我此时要怎么办?

提示词如下:

请帮我code-review 当前代码 

或者以下几种都可以:

帮我检查代码 /  帮我审查代码  /  帮我优化代码

这样AI会看你当前激活的编辑器文件(就是你正在看的那个 tab),然后结合 steering 规则来审查。它检查的是那一个文件的全部内容,

那么如果你只想让他检查某个方法或者某个部分,

提示词这样说:

帮我 code-review 这个文件的 onMounted 部分

那么我想让他帮我精确只检查本次改动的部分,也就是git改动的所有文件,且只检查改动的部分的上下文,(虽然问题比较苛刻,但是有真实使用场景的,哈哈哈哈😂 ),那么提示词可以这样说:

帮我 code-review,用 git diff 查看本次改动,只检查我修改的部分和上下文,不要动没改的代码,有问题直接修

现在这样写提示词好长,每次都要写这么长好麻烦,那么我能点个按钮,就实现这个能力不?

结论是: 当然可以

下面引入一个新东西:Hooks

四、 什么是Hooks ?

KiroHooks 是一种自动化机制 — 当IDE 里发生特定事件时,自动触发 AI 执行某个动作。你可以把它理解为"事件监听器"。

1. 工作原理

一个 Hook 由两部分组成:

  • when(触发条件):什么时候触发?比如你保存了一个文件、创建了新文件、提交了消息等

  • then(执行动作):触发后做什么?比如让 AI 帮你检查代码、或者跑一个命令

2. 支持的触发事件

  1. fileEdited — 你保存文件时触发
  2. fileCreated — 你创建新文件时触发
  3. fileDeleted — 你删除文件时触发
  4. promptSubmit — 你在聊天框发消息时触发
  5. agentStopAI 执行完任务后触发
  6. userTriggered — 你手动点击按钮触发

3. hook规则文件的位置:

efcfcd69-e9e6-4732-a502-7b42d9a3c761.png

{
  "enabled": true,
  "name": "hook的名字",  // 它的名字
  "description": "描述",  // 这里是你对于当前hook 的规则描述
  "version": "1",
  "when": {
    "type": "userTriggered" // 触发时机:手动触发
  },
  "then": {
    "type": "askAgent",
    "prompt": "你可以自己写你的规则"  // 这里是你触发它的规则,比如你让让他按照什么规则做什么
  }
}

举个例子🌰:

比如你想实现"每次保存 .vue.ts 文件时,AI 自动按照你的 code-review-checklist 检查代码并修复问题",Hook 长这样:

 {
  "name": "代码审查",
  "version": "1.0.0",
  "description": "保存文件时自动按照审查规范检查代码问题并修复",
  "when": {
    "type": "fileEdited",
    "patterns": ["**/*.vue", "**/*.ts"]
  },
  "then": {
    "type": "askAgent",
    "prompt": "检查刚刚编辑的文件,按照 code-review-checklist 中的规范,重点检查:1. 定时器是否在 onUnmounted 中清除 2. 异步请求是否有 try-catch 3. 是否存在内存泄漏风险 4. 是否有防重复提交机制。如果发现问题,直接修复。"
  }
}
 

4. 如何触发:

这个 Hook 的效果就是:你每次保存 .vue.ts 文件,AI 会自动读取你改的文件,按照你项目里已有的审查规范检查,发现问题就帮你修。


再举个例子来callback上面的问题🌰:

我想要真正写完一个功能后,在提交git仓库前再检查,也就是上面的问题:我们想在完成了一段工作后,进行阶段性的检查,且不要全文检查,只针对修改部分的上下文,且实现只是点点按钮就能检查。

配置规则如下:

{
  "enabled": true,
  "name": "代码审查与修复",
  "description": "手动触发,按照 code-review-checklist 规范检查当前打开的文件,发现问题直接修复",
  "version": "1",
  "when": {
    "type": "userTriggered"
  },
  "then": {
    "type": "askAgent",
    "prompt": "执行以下步骤:\n\n1. 运行 `git diff` 获取本次所有未提交的改动\n2. 只关注我修改的代码及其上下文(修改行的前后相关逻辑),不要审查文件中未改动的部分\n3. 对每个改动,按照 code-review-checklist.md 规范检查:\n   - 新增的 setInterval/setTimeout 是否有对应的清除\n   - 新增的异步请求是否有 try-catch 和 loading 状态管理\n   - 新增的 watch 是否有防重复机制\n   - 修改部分是否存在空值风险(缺少可选链、数组判空等)\n   - 新增的按钮事件是否有防重复提交\n   - 修改部分的状态管理是否一致\n4. 只报告和修复改动部分引入的问题,已有代码即使不完美也不要动\n5. 修复后简要说明:改了哪个文件的哪部分、问题是什么、怎么修的"
  }
}

相比上面的自动规则,这个手动的hook如何触发?:

Kiro编辑器的最左侧找到这个方框中的图标,点击后找到你的Hooks 列表,在列表里找到你想要执行的 hook ,点击右侧的执行按钮即可解放双手!

3c5e7380-9ba1-4ee3-8dc0-32941b4d61a5.png

触发后它会变成一个执行中的状态:

18e2ee33-b198-4b87-aae1-b05ebf020da3.png

执行结束:

368d2360-e2de-4964-a9cc-39804281a553.png

小结:

现在是不是有种解放双手的快感?😂 我们用SkillsHook 联动,实现AI写代码自动检查是否符合我们的规范,在提交代码前检查当前的修改是否会对原逻辑造成伤害,大大减少了bug的产出。


关于AI你有哪些好用的工具也欢迎评论区留言,一起来探索!

AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用

1. 引言

在大语言模型(LLM)快速发展的今天,几乎所有产品都在借助大模型进行重塑与升级。在过去一段时间,各类旨在提升效率的 AI Agent 如雨后春笋般涌现,尤其是 Coding Agent 的兴起,在一定程度上对前端开发者的职业前景带来了冲击与挑战。一些走在行业前沿的公司甚至开始提倡“前后端再度融合”,这意味着未来开发者可能需要向具备 AI 能力的全栈工程师转型。因此,掌握 AI 全栈相关的知识与技能变得非常重要。

本文将带你通过实战,从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。另外我们将使用 DeepSeek 作为底层模型进行学习。

技术栈前瞻

  • 后端: Python 3, FastAPI (Web 框架), LangChain (LLM 编排), Uvicorn (ASGI 服务器)
  • 前端: Vue 3, TypeScript, Vite (构建工具)
  • 模型: DeepSeek API (兼容 OpenAI 格式)

我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

2. 为什么选择 Python ?

在 AI 领域,Python 无疑是首选的开发语言。因此,如果想通过学习 AI 全栈技术来获得一份理想工作,掌握 Python 几乎是必经之路。这就像在国内想从事后端开发,Java 绝对是不二之选。对于前端背景的同学而言,虽然也可以通过 Node.js 入门 AI 开发,但就整体就业前景和发展空间来看,跟 Node.js 相比 Python 的优势也是断层领先。同时,Python 作为一门入门门槛较低的语言,学习起来相对轻松,所以大家无需过于担心学习难度问题。

最后本人提倡在实战中学习 Python,并且完全可以借助 AI 进行辅导学习。

2. Python 环境配置

我们这里只提供 Windows 环境的讲解,其他的环境自行 AI,Python 的环境搭建还是十分简单的。

  1. 访问官网下载安装包

www.python.org/downloads/

选择对应的平台版本:

image.png

  1. 安装时勾选 "Add Python to PATH"

image.png

  1. 验证安装

打开终端命令工具输入以下命令行:

python --version
pip --version

出现如下信息则说明安装成功了。

image.png

最后编辑器我们可以选择 VS Code,只需在拓展中安装以下插件即可。

image.png

我们前面说到了我们是使用 DeepSeek 作为底层模型进行学习,所以我们需要去 DeepSeek 的 API 开放平台申请一个大模型的 API key。申请地址如下:platform.deepseek.com/api_keys 。当然我们需要充一点钱,就充几块也够我们学习用了。

3. Python 快速入门

3.1 Hello World

我们创建一个 simple-llm-app 的项目目录,然后在根目录创建一个 .env 文件,用于存放项目的环境变量配置,跟前端的项目一样。我们这里设置上面申请到的 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

然后我们可以通过 python-dotenv 库读从 .env 文件中读取它,我们创建一个 test.py 的文件,里面的代码如下:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 打印
print(os.getenv("DEEPSEEK_API_KEY"))

其中 dotenv 库需要安装 python-dotenv 依赖,安装方法也跟安装 npm 包类似,命令如下:

pip install python-dotenv

接着执行 test.py 文件,执行命令跟 Node.js 类似:

python test.py

我们就可以在命令终端看到 .env 文件中的 DeepSeek API key 了。这算是成功输出了 Python 的 Hello world。

3.2 Python 语法入门

接着我们继续了解 Python 的相关语法。在 Python 中,使用 from ... import ...,在 ES6 JavaScript 中,我们使用 import ... from ...。 所以上述代码的 import os -> 类似于 Node.js 中的 import * as os from 'os'os 是一个内置库。 from dotenv import load_dotenv 则类似于从 npm 包中导入一个类,比如: import load_dotenv from 'dotenv'

Python:没有显式的变量声明关键字,直接通过赋值创建变量。

# Python - 直接赋值,无需关键字
name = "张三"
AGE = 25 # 常量(约定)没有内置的常量类型,但通常用全大写变量名表示常量,实际上可以修改
is_student = True

JavaScript:使用 varlet 或 const 声明变量。

// JavaScript - 必须使用关键字
let name = "张三";
const age = 25;  // 常量 使用 `const` 声明常量,不可重新赋值。
var isStudent = true;  // 旧方式

注释对比

Python注释:

  • 单行注释:以 # 开头
# 这是一个Python单行注释
name = "张三"  # 这是行尾注释
  • 多行注释:可以使用三个单引号 ''' 或三个双引号 """ 包裹
'''
这是一个Python多行注释
可以跨越多行
实际上这是字符串,但常用作注释
'''

"""
双引号三引号也可以
这在Python中通常用作文档字符串(docstring)
"""

JavaScript 注释:

  • 单行注释:以 // 开头
// 这是一个JavaScript单行注释
let name = "张三";  // 这是行尾注释
  • 多行注释:以 /* 开头,以 */ 结尾
/*
 这是一个JavaScript多行注释
 可以跨越多行
 这是真正的注释语法
*/


/**
 * 用户类,表示系统中的一个用户
 * @class
 */
class User {
}

好了我们不贪杯,实战中遇到不同的 Python 语法,我们再针对学习或者借助 AI 通过与 JavaScript 语法进行横向对比,对于有一定编程基础的我们,肯定非常容易理解的。相信通过上述 Python 语法的学习,聪明的你再回头看上述示例的 Python 代码,肯定可以看懂了。

我们这里只是简单介绍上面代码中涉及到的 Python 语法,本人推荐在实战中进行学习。更多 JavaScript 视觉学习 Python:langshift.dev/zh-cn/docs/…

3.3 FastAPI 框架快速入门

3.3.1 FastAPI 是什么

FastAPI 是一个现代、高性能(与 NodeJS 和 Go 相当)的 Web 框架,用于构建 API,基于 Python 3.6+ 并使用了标准的 Python 类型提示。但它本身不提供完整的 Web 服务器功能,而是通过 ASGI(Asynchronous Server Gateway Interface)与服务器进行通信。

Uvicorn 是一个高性能的 ASGI 服务器,它支持异步编程,能够运行 FastAPI 这样的异步 Web 应用。所以 FastAPI 需要配合 Uvicorn 使用,这样才能够充分发挥 FastAPI 的异步特性,提供极高的性能。同时,Uvicorn 在开发和部署时都非常方便。

简单来说

  • FastAPI 负责:路由、验证、序列化、依赖注入等应用逻辑
  • Uvicorn 负责:HTTP 协议解析、并发处理、连接管理等服务器功能

两者结合形成了现代 Python Web 开发的黄金组合,既能享受 Python 的便捷,又能获得接近 Go、Node.js 的性能。

3.3.2 基本示例

我们创建一个 server.py 文件,输入以下示例代码:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。"}

# 程序的入口点
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.1.1.1", port=9527)

上述代码引用了两个依赖 fastapi 和 uvicorn,我们通过 pip 进行安装一下:

pip install fastapi uvicorn

然后我们在终端启动服务:python server.py,运行结果如下:

image.png

接着我们在浏览器打开 http://127.1.1.1:9527 显示如下:

image.png

3.3.3 路径参数和查询参数

示例:

@app.get("/items/{id}")
def read_item(
    id: int, 
    limit: int = 10,         # 默认值
    q: Optional[str] = None, # 可选参数
    short: bool = False,     # 默认值
    tags: List[str] = []     # 列表参数
):
    item = {"id": id, "limit": limit, "tags": tags}
    if q:
        item.update({"q": q})
    if not short:
        item.update({"desc": "长说明"})
    return item

重启服务,在浏览器输入:http://127.1.1.1:9527/items/1?q=cobyte ,结果如下:

image.png

总结

  • 路径参数:在路径中声明的参数,如 id
  • 查询参数:作为函数参数,但不是路径参数,将自动解释为查询参数。
3.3.4 FastAPI 中的模型定义

在 FastAPI 中,我们经常需要处理来自客户端的请求数据,例如 POST 请求的 JSON 体。为了确保数据的正确性,我们需要验证数据是否符合预期的格式和类型。使用 Pydantic 模型可以让我们以一种声明式的方式定义数据的结构,并自动进行验证。

Pydantic 是一个 Python 库,用于数据验证和设置管理,主要基于 Python 类型提示(type hints)。它可以在运行时提供类型检查,并且当数据无效时提供详细的错误信息。

Pydantic 的核心功能是定义数据的结构(模型),并自动验证传入的数据是否符合这个结构。它非常适用于以下场景:

  • 验证用户输入(例如 API 请求的数据)
  • 配置管理
  • 数据序列化和反序列化(例如将 JSON 数据转换为 Python 对象)

Pydantic 模型使用 Python 的类来定义,类的属性使用类型注解来指定类型,并且可以设置默认值。

请求体(Request Body)和响应模型(Response Model)的示例如:

from pydantic import BaseModel, validator, Field
from typing import Optional, List
import re

# 请求体(Request Body)
class UserRequest(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    password: str
    email: str
    @validator('username')
    def username_alphanumeric(cls, v):
        if not re.match('^[a-zA-Z0-9_]+$', v):
            raise ValueError('只能包含字母、数字和下划线')
        return v
    
    @validator('email')
    def email_valid(cls, v):
        if '@' not in v:
            raise ValueError('无效的邮箱地址')
        return v.lower()  # 转换为小写
    
    @validator('password')
    def password_strong(cls, v):
        if len(v) < 6:
            raise ValueError('密码至少6位')
        return v
# 响应模型(Response Model)
class UserResponse(BaseModel):
    username: str
    email: str

@app.post("/user/", response_model=UserResponse)
async def create_user(user: UserRequest):
    # 密码会被过滤,不会出现在响应中
    return user

FastAPI 会自动从 Pydantic 模型生成 API 文档,我们在 server.py 文件中添加了上述示例之后,重启服务,访问 http://127.1.1.1:9527/docs 可以看到:

image.png

并且我们还可以在文档地址中进行测试,这里就不展开讲了。

3.3.5 异步和中间件

示例:

from fastapi import Request

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Process-Time"] = str(process_time)
    return response

我们可以看到 Python 的这个异步语法跟 JavaScript 的 async/await 是一样的语法。

3.3.6 CORS 配置

通过设置 CORS 配置允许前端跨域访问。

from fastapi.middleware.cors import CORSMiddleware
# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],  # 允许的方法
    allow_headers=["*"],  # 允许的头部
)

到此本文所用到的 FastAPI 知识就基本介绍完毕了,后续再在实战中进行学习,先上了 AI 全栈的车再说。

4. LLM 和 OpenAI 接口快速入门

4.1 入门示例代码

让我们从安装依赖开始,借助 DeepSeek 大模型一起探索 OpenAI 接口规范。

pip install openai

接着我们在 test.py 中添加如下代码:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 加载 OpenAI 库,从这里也可以看到 Python 的库加载顺序跟 JavaScript ES6 import 是不一样,反而有点像 requrie
from openai import OpenAI

# 初始化客户端
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"), # 身份验证凭证,确保你有权访问 API
    base_url="https://api.deepseek.com" # 将请求重定向到 DeepSeek 的服务器(而非 OpenAI)
)
# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ]
)
# 打印结果
print(response.choices[0].message.content.strip())

终端输出结果如下:

image.png

可以看到我们成功调用了 DeepSeek 大模型。

在 openai 中,返回的 response 对象是一个 Pydantic 模型,如果我们想详细查看 response 返回的结果,可以使用它自带的 .model_dump_json() 方法。

# 使用 model_dump_json 并指定缩进
print(response.model_dump_json(indent=2))

可以看到通过上述方式打印大模型响应的信息如下:

image.png

4.2 choices 字段详解

我们从上面打印的结果可以了知道,大模型返回的文本信息是存储在 choices 字段中的,所以我们来了解一下它。

在调用 chat.completions.create 时,如果设置了 n 参数(n>1),那么模型会生成多个输出,此时 choices 字段就会包含多个元素。每个 choice 代表一个可能的响应,我们可以通过遍历 choices 来获取所有响应。

另外,即使 n=1(默认值),choices 也是一个列表,只不过只包含一个元素。所以我们上述例子中才通过 response.choices[0] 来获取大模型的返回结果。

4.3 流式响应

因为大模型本质上是一个预测生成器,简单来说就是你输入一句话,大模型就预测下一个字。因此我们希望在模型生成文本的同时就显示给用户,提高交互的实时性。这就是流式响应。代码设置如下:

# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ],
+  stream=True, # 启用流式传输
)

+# response是一个生成器,在Python中,生成器是一种迭代器,每次迭代返回一个值。这里,每次迭代返回一个chunk(部分响应)。
+for chunk in response:                           # 1. 遍历响应流
+    if chunk.choices[0].delta.content:           # 2. 检查是否有内容
+        print(chunk.choices[0].delta.content,    # 3. 打印内容
+              end="",                            # 4. 不换行
+              flush=True)                        # 5. 立即刷新缓冲区

输出结果如下:

20260207-132313.gif

4.4 temperature 参数

我个人觉得那么多大模型参数中 temperature 参数还是比较重要的,值得我们了解一下。模型在生成每一个词时,都会计算一个所有可能的下一个词的概率分布(例如,“苹果”概率0.3,“香蕉”概率0.5,“水果”概率0.2)。temperature 的值会影响这个概率分布的形状,从而改变模型最终根据这个分布进行“抽样”选择的结果。

一个简单的比喻:选餐厅吃饭

  • Temperature = 0.0永远只去评分最高、去过无数次的那一家最保险的餐厅。结果最稳定,但永远没有新体验。
  • Temperature = 1.0大多数时候去那家最好的,但偶尔也会根据评价试试附近其他不错的餐厅。平衡了可靠性和新鲜感。
  • Temperature = 1.5经常尝试新餐厅,甚至包括一些评价奇特或小众的地方。体验非常丰富,但有时会“踩雷”。

总结与建议

  1. 追求确定性时调低 (接近0) :当你需要精确、可靠、可复现的结果时,如生成代码、数学推导、事实问答、指令严格遵循。
  2. 追求创造性和多样性时调高 (>1.0) :当你需要创意、多样化表达、故事生成、诗歌时。
  3. 通用场景用中间值 (0.8-1.2) :大多数对话、摘要、分析等任务,这个范围能提供既连贯又有一定灵活性的输出。

4.5 消息角色

在 OpenAI API 中,messages 数组中的每条消息都有一个 role 字段,它定义了消息的来源和用途。消息角色主要有三种:system、user、assistant。此外,在后续的更新中,还引入了 tool 和 function 等角色,但最基础的是前三种。

1. system (系统)

  • 作用: 设置助手的背景、行为、指令等。

  • 特点:

    • 通常作为第一条消息,用于设定对话的上下文和规则。
    • 不是必须的,但可以显著影响助手的行为。
  • 示例:

    {"role": "system", "content": "你是一个专业的翻译助手,只能将中文翻译成英文,其他问题一律不回答。"}
    

2. user (用户)

  • 作用: 用户输入的问题或指令

  • 特点:

    • 代表对话中的人类用户
    • 每个请求必须至少包含一条 user 消息
    • 通常是最后一条消息(除了流式响应)
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个有帮助的助手"},
        {"role": "user", "content": "什么是机器学习?"}
    ]
    

3. assistant (助手)

  • 作用: 代表助手之前的回复。

  • 特点:

    • 在多轮对话中保存历史回复
    • 帮助模型保持对话连贯性
    • 在单轮对话中不需要此角色
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个数学老师"},
        {"role": "user", "content": "2+2等于多少?"},
        {"role": "assistant", "content": "2+2等于4"},
        {"role": "user", "content": "那3+3呢?"}  # 模型知道这是新问题
    ]
    

通过合理组合这些角色,你可以构建从简单问答到复杂多轮对话的各种应用场景。记住:清晰的角色定义和恰当的消息组织是获得高质量回复的基础。我们这里先介绍前三种核心角色。

5. LangChain 入门

5.1 怎么理解 LangChain 框架

从前端的视角来理解,LangChain 就好比是 Vue 或 React 这类框架。在前端开发中,如果没有 Vue 或 React,我们就需要直接编写大量操作浏览器 API 的底层代码;而有了这类框架,它们封装了常见的交互逻辑和状态管理,让开发变得更高效、更结构化。类似地,LangChain 实际上是一套封装了大型语言模型常见操作模式的方案,它帮助开发者更便捷地调用、组合与管理大模型的能力,而无需每次都从头编写复杂的模型交互代码。

5.2 LangChain 调用 LLM 示例

接着我们在项目根目录下创建一个 llm-app.py 文件,输入以下内容:

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv

# 1. 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()

# 2. 创建组件
# 相对于上面的使用 OpenAI 的接口,现在经过 LangChain 封装后确实简洁了很多
llm = ChatOpenAI(
    model="deepseek-chat", 
    temperature=0.7,
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1"
)

# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
prompt = ChatPromptTemplate.from_template("{question}")

# 创建解析器
parser = StrOutputParser()
# 将AI响应转换为字符串,通过前面的知识我们知道大模型返回的数据一般包含很多数据,
# 很多时候我们只需要其中的文本内容。`StrOutputParser` 就是用来提取这个文本内容的

# 3. 组合链 (LCEL 语法) Python LangChain 常见的链式调用
chain = prompt | llm | parser
# 等价于:输入 → 模板填充 → AI处理 → 结果解析

# 4. 执行
result = chain.invoke({"question": "你是谁?"})
# 内部执行:填充"你是谁?" → 调用API → 解析响应 → 返回字符串

# 5. 打印结果
print(result)

然后在终端安装对应的依赖(这个步骤跟前端也很像,所以学习 Python 是很简单的):

pip install langchain_openai langchain_core dotenv

接着在终端执行

# 跟前端的 node llm-app.js 等价
python llm-app.py

终端输出结果如下:

image.png

可以看到我们成功执行了一个 Python + LangChain 的应用程序。

5.2 消息模板系统

我们上面的注释讲解了 prompt = ChatPromptTemplate.from_template("{question}") 这句代码默认创建了一个人类角色的提示模板,也就是 {"role": "user", "content": "用户输入的内容"}

LangChain 作为一个强大的 LLM 应用开发框架, 为了让开发者能够精确控制对话的流程和结构,提供了灵活且强大的消息模板系统。LangChain 的消息模板系统基于角色(role)的概念,将对话分解为不同类型的信息单元。目前的类型如下:

角色 用途 对应 OpenAI 角色
SystemMessagePromptTemplate system 系统指令、设定 system
HumanMessagePromptTemplate human 用户输入 user
AssistantMessagePromptTemplate assistant AI 回复 assistant
AIMessagePromptTemplate ai AI 回复(别名) assistant
ToolMessagePromptTemplate tool 工具调用结果 tool
FunctionMessagePromptTemplate function 函数调用结果 function

ChatPromptTemplate 则是消息系统的核心容器,负责协调各种消息类型:

from langchain_core.prompts import (
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
    AssistantMessagePromptTemplate
)
system = SystemMessagePromptTemplate.from_template(...)
human = HumanMessagePromptTemplate.from_template(...)
assistant = AssistantMessagePromptTemplate.from_template(...)
prompt = ChatPromptTemplate.from_messages([system, human, assistant])

所以上述入门实例代码可以进行以下修改:

-from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.prompts import ChatPromptTemplate,HumanMessagePromptTemplate
# 省略...
-# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
-prompt = ChatPromptTemplate.from_template("{question}")
+human = HumanMessagePromptTemplate.from_template("{question}")
+prompt = ChatPromptTemplate.from_messages([human])
# 省略...

然后重新在终端执行 python llm-app.py 依然正常输出。

同时通过 LangChain 消息模型来理解大模型的调用过程也变得十分的清晰,所以整个流程是:

输入 → prompt → llm → parser → 输出
     ↓
{"question": "你是谁?"}
     ↓
prompt 处理:创建消息 "你是谁?"
     ↓
llm 处理:调用 LLM 处理,返回 AIMessage 对象
     ↓
parser 处理:提取文本内容
     ↓
最终结果字符串

在 LangChain 中还有一个最基础的模板类 PromptTemplate 用于构建字符串提示。下面我们也来了解一下它的基本用法。

from langchain_core.prompts import PromptTemplate

# 方式1:使用 from_template 类方法(最常用)
prompt = PromptTemplate.from_template("请解释什么是{concept}。")

# 方式2:直接实例化
prompt = PromptTemplate(
    input_variables=["concept"], 
    template="请解释什么是{concept}。"
)

综上所述我们通过理解和掌握 LangChain 这些核心概念,才能高效地构建可靠、可维护的 LLM 应用。此外,LangChain 的消息模板系统仍在不断发展当中,我们需要不断地持续关注。

5.3 LangChain 链式调用(管道操作符)

在 LangChain 中所谓的链式调用是通过管道操作符 | 来实现的,也就是通过 | 实现将一个函数的输出作为下一个函数的输入。

例如上述的示例代码中的:

# LangChain 中的管道操作
chain = prompt | llm | output_parser
  • 等价于手动执行链的每一步:
# 第一步:prompt 处理
messages = prompt.invoke({"question": "你是谁?"})
# messages = [HumanMessage(content="你是谁?")]

# 第二步:llm 处理
response = llm.invoke(messages)
# response = AIMessage(content="我是DeepSeek...")

# 第三步:parser 处理
result = parser.invoke(response)
# result = "我是DeepSeek..."

在标准 Python 语法中,| 是按位或操作符,用于:

  • 整数的按位运算:5 | 3 = 7
  • 集合的并集运算:{1, 2} | {2, 3} = {1, 2, 3}
  • 从 Python 3.10 开始,用于类型联合:int | str

但 LangChain 通过 重载(overload)  | 操作符,赋予了它新的含义:

  • | 在 LangChain 中是一种语法糖,让链式操作更直观
  • 它不是 Python 的新语法,而是通过操作符重载实现的框架特定功能
  • 这种设计让 LangChain 的代码更加简洁和易读

6. LLM 聊天应用后端

6.1 后端架构设计

我们遵循单一职责原则(SRP)进行分层架构设计,将系统划分为API层、业务层和数据层,旨在实现高内聚、低耦合,提升代码的可维护性、可测试性和可扩展性。

API层  专注于处理 HTTP 协议相关的逻辑,包括路由定义、请求验证、响应序列化和跨域处理等。它作为系统的入口点,负责与客户端进行通信,并将业务逻辑委托给下层。这种设计使得我们可以独立地调整 API 暴露方式(如支持 WebSocket)而不影响核心业务逻辑。

业务层  封装 LLM 的核心应用逻辑,例如与 AI 模型的交互、对话历史管理和流式生成等。这一层独立于 Web 框架,使得业务逻辑可以复用于其他场景(如命令行界面或批处理任务)。同时,业务层的单一职责确保了我们能够针对 LLM 交互进行优化和测试,而无需关心 HTTP 细节。

数据层  通过 Pydantic 定义系统的数据模型,包括请求、响应结构和内部数据传输对象。通过集中管理数据模型,我们确保了数据格式的一致性,并便于进行数据验证和类型提示。这种分离使得数据结构的变更更加可控,同时也为生成 API 文档提供了便利。

6.1 实现业务层和数据层

实现业务层其实就是封装 LLM 的核心应用逻辑。通过将复杂的 LLM 调用逻辑、提示工程和流式处理封装在独立的类中,这样 API 层只需关注请求与响应,而无需了解 LangChain 或特定 API 的细节。这使得底层技术栈的迭代或更换(例如从 LangChain 切换到其他操作大模型的框架或更改 LangChain 最新的 API)变得轻而易举,只需修改封装类内部实现,而对外接口保持不变,实现了有效隔离。

创建 ./backend/llm_app.py 文件,内容如下:

import os
from typing import Generator
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 加载环境变量
load_dotenv()

class LLMApp:
    def __init__(self, model_name="deepseek-chat", temperature=0.7):
        """
        初始化 LLM 应用程序
        """
        # 检查 DeepSeek API 密钥
        if not os.getenv("DEEPSEEK_API_KEY"):
            raise ValueError("请在 .env 文件中设置 DEEPSEEK_API_KEY 环境变量")
        
        # 初始化配置
        self.model_name = model_name
        self.temperature = temperature
        self.api_key = os.getenv("DEEPSEEK_API_KEY")
        self.base_url = "https://api.deepseek.com/v1"
        
        # 初始化非流式 LLM (用于普通任务)
        self.llm = self._create_llm(streaming=False)
        
        # 初始化流式 LLM (用于流式对话)
        self.streaming_llm = self._create_llm(streaming=True)
        
        # 输出解析器
        self.output_parser = StrOutputParser()
        
        # 初始化对话链
        self._setup_chains()
    
    def _create_llm(self, streaming: bool = False):
        """创建 LLM 实例"""
        return ChatOpenAI(
            model_name=self.model_name,
            temperature=self.temperature,
            api_key=self.api_key,
            base_url=self.base_url,
            streaming=streaming
        )
    
    def _setup_chains(self):
        """设置处理链"""
        # 带上下文的对话 Prompt
        conversation_prompt = PromptTemplate(
            input_variables=["chat_history", "user_input"],
            template="""你是一个有用的 AI 助手。请根据对话历史回答用户的问题。
            
            对话历史:
            {chat_history}
            
            用户:{user_input}
            助手:"""
        )
        # 注意:这里我们只定义 prompt,具体执行时再组合
        self.conversation_prompt = conversation_prompt

    def format_history(self, history_list) -> str:
        """格式化聊天历史"""
        if not history_list:
            return "无历史对话"
        
        formatted = []
        for msg in history_list:
            # 兼容 Pydantic model 或 dict
            if isinstance(msg, dict):
                role = msg.get('role', 'unknown')
                content = msg.get('content', '')
            else:
                role = getattr(msg, 'role', 'unknown')
                content = getattr(msg, 'content', '')
                
            formatted.append(f"{role}: {content}")
        
        return "\n".join(formatted[-10:])  # 只保留最近 10 条

    def stream_chat(self, user_input: str, chat_history: list) -> Generator[str, None, None]:
        """流式对话生成器"""
        try:
            history_text = self.format_history(chat_history)
            
            # 构建链:Prompt | StreamingLLM | OutputParser
            chain = self.conversation_prompt | self.streaming_llm | self.output_parser
            
            # 执行流式生成
            for chunk in chain.stream({
                "chat_history": history_text,
                "user_input": user_input
            }):
                yield chunk
                
        except Exception as e:
            yield f"Error: {str(e)}"

接下来我们对上述封装的 LLM 类的功能进行测试,测试前先在 ./backend/.env 文件中添加 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

接着创建 ./backend/test.py 文件写上以下测试代码。

from llm_app import LLMApp

# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"

# 收集流式响应
response_chunks = []
for chunk in llmApp.stream_chat(user_input, chat_history):
    response_chunks.append(chunk)
    # 模拟实时显示
    print(chunk, end="", flush=True)

# 合并响应
full_response = "".join(response_chunks)
print(f"\n完整响应: {full_response}")

测试结果如下:

20260208-172852.gif

接着我们通过 Pydantic 来定义数据的结构(模型)

创建 ./backend/models.py 文件,内容如下:

from pydantic import BaseModel
from typing import List, Optional

class ChatMessage(BaseModel):
    """单条聊天消息"""
    role: str  # "user" 或 "assistant"
    content: str

class ChatRequest(BaseModel):
    """聊天请求模型"""
    message: str
    chat_history: Optional[List[ChatMessage]] = []

修改 ./backend/test.py 文件,内容如下:

import json
import asyncio
from llm_app import LLMApp
from models import ChatRequest, ChatMessage


# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"
# 模拟 SSE 的流式聊天响应
async def chat_stream(request: ChatRequest):
    # 1. 发送开始事件
    yield f"data: {json.dumps({'type': 'start'})}\n\n"
    await asyncio.sleep(0.01) # 让出控制权,以便运行其他任务。
    
    full_response = ""
    
    # 2. 生成并发送 token
    for token in llmApp.stream_chat(request.message, request.chat_history):
        full_response += token
        yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
        await asyncio.sleep(0.01)
    
    # 3. 发送结束事件
    yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"

# 异步测试函数
async def test_chat_stream():
    # 使用 Pydantic 模型实现数据序列化和反序列化(即将JSON数据转换为Python对象)
    request = ChatRequest(message=user_input, chat_history=chat_history)
    async for chunk in chat_stream(request):
        print(chunk)
# 在异步编程中,我们使用asyncio.run()来运行一个异步函数(coroutine)作为程序的入口点。
asyncio.run(test_chat_stream())

打印结果如下:

image.png

在上述的测试代码中的 chat_stream 函数实现一个基于 Server-Sent Events (SSE) 的流式聊天响应的异步生成器,它接收一个 ChatRequest 对象,然后逐步生成事件流。事件流格式遵循 SSE 规范,每个事件以 "data: " 开头,后跟 JSON 字符串,并以两个换行符结束。

  1. 首先,发送一个开始事件,通知客户端开始生成响应。
  2. 然后,通过调用 llmApp.stream_chat 方法,逐个获取 token,并将每个 token 作为一个事件发送。
  3. 在发送每个 token 事件后,使用 await asyncio.sleep(0.01) 来让出控制权,这样其他任务可以运行,避免阻塞。
  4. 同时,将每个 token 累加到 full_response 中,以便在最后发送整个响应。
  5. 最后,发送一个结束事件,并包含完整的响应内容。

这样设计的好处:

  • 流式传输:可以逐步将响应发送给客户端,客户端可以实时看到生成的 token,提升用户体验(如打字机效果)。
  • 异步:使用异步生成器,可以在等待模型生成下一个 token 时让出控制权,提高并发性能。
  • 事件驱动:通过定义不同类型的事件(开始、token、结束),客户端可以方便地根据事件类型进行处理。

6.2 实现 API 层

上面测试代码中实现的 chat_stream 函数,其实就是我们接下来要实现的 流式对话接口,即接收用户的消息和聊天历史,通过流式方式返回 LLM 的响应。同时我们再实现一个健康检查接口,提供服务器的健康状态,包括 LLM 应用是否初始化成功、模型名称等,便于监控。

根据上面所学的知识,我们实现一个基于 FastAPI 的 LLM 聊天 API 服务。

我们创建 ./backend/server.py 文件,内容如下:

import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from llm_app import LLMApp
from models import ChatRequest, HealthResponse

app = FastAPI(title="Cobyte LLM Chat API")

# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局 LLM 应用实例
llm_app = None

@app.on_event("startup")
async def startup_event():
    """应用启动时初始化 LLM"""
    global llm_app
    try:
        print("正在初始化 LLM 应用...")
        llm_app = LLMApp()
        print("✅ LLM 应用初始化成功")
    except Exception as e:
        print(f"❌ LLM 应用初始化失败: {e}")

@app.get("/api/health")
async def health_check():
    """健康检查接口"""
    return HealthResponse(
        status="healthy" if llm_app else "unhealthy",
        model="deepseek-chat",
        api_configured=llm_app is not None,
        timestamp=datetime.now().isoformat()
    )

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    """流式对话接口"""
    if not llm_app:
        raise HTTPException(status_code=500, detail="LLM 服务未就绪")
    
    async def generate():
        try:
            # 1. 发送开始事件
            yield f"data: {json.dumps({'type': 'start'})}\n\n"
            await asyncio.sleep(0.01) # 让出控制权
            
            full_response = ""
            
            # 2. 生成并发送 token
            # 注意:llm_app.stream_chat 是同步生成器,但在 FastAPI 中可以正常工作
            # 如果需要完全异步,需要使用 AsyncChatOpenAI,这里为了简单保持同步调用
            for token in llm_app.stream_chat(request.message, request.chat_history):
                full_response += token
                yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
                await asyncio.sleep(0.01)
            
            # 3. 发送结束事件
            yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"
            
        except Exception as e:
            error_msg = str(e)
            print(f"生成错误: {error_msg}")
            yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

至此我们基于 FastAPI 实现了 API 层。核心功能就是提供了两个 API:

  1. 流式对话接口 /api/chat/stream

    • 支持 Server-Sent Events (SSE) 流式响应
    • 接收用户消息,实时返回 AI 生成的回复
    • 支持对话历史管理
  2. 健康检查接口 /api/health

    • 检查服务状态
    • 返回 API 配置信息

6.3 依赖管理

为了更好地管理我们的依赖,我们可以创建一个 ./backend/requirements.txt 文件,将使用到的依赖都设置到这个文件中:

fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0
langchain>=1.2.9
langchain-openai>=0.0.5
pydantic>=2.5.0

这样我们就可以进行以下方式进行安装依赖了。

# 安装依赖
pip install -r requirements.txt

7. 前端聊天界面

先创建一个 Vue3 + TS 的前端项目,我们在根目录下执行以下命令:

npm create vite@latest frontend --template vue-ts

接下来我们主要实现以下核心功能:

  1. 对话界面

    • 消息列表展示(用户消息 + AI 回复)
    • 输入框 + 发送按钮
    • 流式显示 AI 回复(逐字显示效果)
    • 加载状态提示
  2. 交互功能

    • 发送消息(Enter 键/点击按钮)
    • 清空对话历史
    • 滚动到最新消息

./frontend/src/types/chat.ts 文件如下:

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: number
  streaming?: boolean  // 是否正在流式生成
}

export interface ChatRequest {
  message: string
  chat_history: Array<{
    role: string
    content: string
  }>
}

export interface SSEEvent {
  type: 'start' | 'token' | 'end' | 'error'
  content?: string
  full_response?: string
  message?: string
}

./frontend/src/api/chat.ts 文件内容如下:

import type { ChatRequest, SSEEvent } from '../types/chat'

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'

export class ChatAPI {
  /**
   * 流式对话接口
   */
  static streamChat(
    payload: ChatRequest,
    onToken: (token: string) => void,
    onComplete: (fullResponse: string) => void,
    onError: (error: string) => void
  ): () => void {
    // 使用 fetch API 配合 ReadableStream 来处理 POST 请求的流式响应
    // 因为标准的 EventSource 不支持 POST 请求
    const controller = new AbortController()
    
    const fetchStream = async () => {
      try {
        const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(payload),
          signal: controller.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const reader = response.body?.getReader()
        const decoder = new TextDecoder()
        
        if (!reader) throw new Error('Response body is null')

        let buffer = ''

        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          const chunk = decoder.decode(value, { stream: true })
          buffer += chunk
          
          // 处理 buffer 中的每一行
          const lines = buffer.split('\n\n')
          buffer = lines.pop() || '' // 保留最后一个可能不完整的块
          
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const jsonStr = line.slice(6)
              try {
                const data: SSEEvent = JSON.parse(jsonStr)
                
                switch (data.type) {
                  case 'start':
                    break
                  case 'token':
                    if (data.content) onToken(data.content)
                    break
                  case 'end':
                    if (data.full_response) onComplete(data.full_response)
                    return // 正常结束
                  case 'error':
                    onError(data.message || 'Unknown error')
                    return
                }
              } catch (e) {
                console.error('JSON parse error:', e)
              }
            }
          }
        }
      } catch (error: any) {
        if (error.name === 'AbortError') return
        onError(error.message)
      }
    }

    fetchStream()

    // 返回取消函数
    return () => controller.abort()
  }

  /**
   * 健康检查
   */
  static async healthCheck() {
    try {
      const response = await fetch(`${API_BASE_URL}/api/health`)
      return await response.json()
    } catch (error) {
      console.error('Health check failed', error)
      return { status: 'error' }
    }
  }
}

./frontend/src/composables/useChat.ts 文件内容如下:

import { ref, nextTick } from 'vue'
import type { Message } from '../types/chat'
import { ChatAPI } from '../api/chat'

export function useChat() {
  const messages = ref<Message[]>([])
  const isLoading = ref(false)
  const currentStreamingMessage = ref<Message | null>(null)
  
  // 用于取消当前的请求
  let cancelStream: (() => void) | null = null

  /**
   * 滚动到底部
   */
  const scrollToBottom = () => {
    nextTick(() => {
      const container = document.querySelector('.message-list')
      if (container) {
        container.scrollTo({
          top: container.scrollHeight,
          behavior: 'smooth'
        })
      }
    })
  }

  /**
   * 发送消息
   */
  const sendMessage = async (content: string) => {
    if (!content.trim() || isLoading.value) return

    // 1. 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: content.trim(),
      timestamp: Date.now()
    }
    messages.value.push(userMessage)
    
    // 准备发送给后端的历史记录(去掉刚加的这一条,因为后端只要之前的)
    // 或者你可以根据设计决定是否包含当前条,通常 API 设计是:新消息 + 历史
    // 我们的后端设计是:message + chat_history
    const historyPayload = messages.value.slice(0, -1).map(m => ({
      role: m.role,
      content: m.content
    }))

    // 2. 创建 AI 消息占位符
    const aiMessage: Message = {
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      streaming: true
    }
    messages.value.push(aiMessage)
    currentStreamingMessage.value = aiMessage
    isLoading.value = true
    
    scrollToBottom()

    // 3. 调用流式 API
    cancelStream = ChatAPI.streamChat(
      {
        message: content.trim(),
        chat_history: historyPayload
      },
      // onToken
      (token) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += token
          scrollToBottom()
        }
      },
      // onComplete
      (fullResponse) => {
        if (currentStreamingMessage.value) {
          // 确保内容完整
          if (currentStreamingMessage.value.content !== fullResponse && fullResponse) {
             currentStreamingMessage.value.content = fullResponse
          }
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      },
      // onError
      (error) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += `\n[错误: ${error}]`
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      }
    )
  }

  /**
   * 清空历史
   */
  const clearHistory = () => {
    if (cancelStream) {
      cancelStream()
      cancelStream = null
    }
    messages.value = []
    isLoading.value = false
    currentStreamingMessage.value = null
  }

  return {
    messages,
    isLoading,
    sendMessage,
    clearHistory
  }
}

./frontend/src/App.vue 文件内容如下:

<template>
  <div class="app-container">
    <header class="chat-header">
      <div class="header-content">
        <h1>🤖 DeepSeek 对话助手</h1>
        <div class="status-badge" :class="{ online: isServerOnline }">
          {{ isServerOnline ? '在线' : '离线' }}
        </div>
      </div>
      <button @click="clearHistory" class="clear-btn" title="清空对话">
        🗑️
      </button>
    </header>

    <main class="message-list">
      <div v-if="messages.length === 0" class="empty-state">
        <p>👋 你好!我是基于 DeepSeek 的 AI 助手。</p>
        <p>请在下方输入问题开始对话。</p>
      </div>

      <div 
        v-for="msg in messages" 
        :key="msg.id" 
        class="message-wrapper"
        :class="msg.role"
      >
        <div class="avatar">
          {{ msg.role === 'user' ? '👤' : '🤖' }}
        </div>
        <div class="message-content">
          <div class="bubble">
            {{ msg.content }}
            <span v-if="msg.streaming" class="cursor">|</span>
          </div>
        </div>
      </div>
    </main>

    <footer class="input-area">
      <div class="input-container">
        <textarea
          v-model="inputContent"
          placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
          @keydown.enter.exact.prevent="handleSend"
          :disabled="isLoading"
          rows="1"
          ref="textareaRef"
        ></textarea>
        <button 
          @click="handleSend" 
          :disabled="isLoading || !inputContent.trim()"
          class="send-btn"
        >
          {{ isLoading ? '...' : '发送' }}
        </button>
      </div>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useChat } from './composables/useChat'
import { ChatAPI } from './api/chat'

const { messages, isLoading, sendMessage, clearHistory } = useChat()
const inputContent = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isServerOnline = ref(false)

// 检查服务器状态
onMounted(async () => {
  const health = await ChatAPI.healthCheck()
  isServerOnline.value = health.status === 'healthy'
})

// 自动调整输入框高度
watch(inputContent, () => {
  if (textareaRef.value) {
    textareaRef.value.style.height = 'auto'
    textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
  }
})

const handleSend = () => {
  if (inputContent.value.trim() && !isLoading.value) {
    sendMessage(inputContent.value)
    inputContent.value = ''
    // 重置高度
    if (textareaRef.value) {
      textareaRef.value.style.height = 'auto'
    }
  }
}
</script>

<style>
:root {
  --primary-color: #4a90e2;
  --bg-color: #f5f7fa;
  --chat-bg: #ffffff;
  --user-msg-bg: #e3f2fd;
  --bot-msg-bg: #f5f5f5;
  --border-color: #e0e0e0;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  background-color: var(--bg-color);
  height: 100vh;
  overflow: hidden;
}

.app-container {
  max-width: 800px;
  margin: 0 auto;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--chat-bg);
  box-shadow: 0 0 20px rgba(0,0,0,0.05);
}

/* Header */
.chat-header {
  padding: 1rem;
  border-bottom: 1px solid var(--border-color);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: white;
  z-index: 10;
}

.header-content h1 {
  font-size: 1.2rem;
  color: #333;
}

.status-badge {
  font-size: 0.8rem;
  padding: 2px 6px;
  border-radius: 4px;
  background: #ff5252;
  color: white;
  display: inline-block;
  margin-left: 8px;
}

.status-badge.online {
  background: #4caf50;
}

.clear-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 5px;
  border-radius: 50%;
  transition: background 0.2s;
}

.clear-btn:hover {
  background: #f0f0f0;
}

/* Message List */
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.empty-state {
  text-align: center;
  margin-top: 50px;
  color: #888;
}

.message-wrapper {
  display: flex;
  gap: 12px;
  max-width: 85%;
}

.message-wrapper.user {
  align-self: flex-end;
  flex-direction: row-reverse;
}

.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: #eee;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.2rem;
  flex-shrink: 0;
}

.bubble {
  padding: 12px 16px;
  border-radius: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-wrapper.user .bubble {
  background: var(--user-msg-bg);
  color: #0d47a1;
  border-radius: 12px 2px 12px 12px;
}

.message-wrapper.assistant .bubble {
  background: var(--bot-msg-bg);
  color: #333;
  border-radius: 2px 12px 12px 12px;
}

.cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: #333;
  animation: blink 1s infinite;
  vertical-align: middle;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Input Area */
.input-area {
  padding: 20px;
  border-top: 1px solid var(--border-color);
  background: white;
}

.input-container {
  display: flex;
  gap: 10px;
  align-items: flex-end;
  background: #f8f9fa;
  padding: 10px;
  border-radius: 12px;
  border: 1px solid var(--border-color);
}

textarea {
  flex: 1;
  border: none;
  background: transparent;
  resize: none;
  max-height: 150px;
  padding: 8px;
  font-size: 1rem;
  font-family: inherit;
  outline: none;
}

.send-btn {
  background: var(--primary-color);
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
  transition: opacity 0.2s;
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

./frontend/src/style.css 文件内容如下:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

#app {
  width: 100%;
  height: 100vh;
}

.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #1a1a1a;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.header {
  text-align: center;
  margin-bottom: 2rem;
}

.header h1 {
  font-size: 2rem;
  color: #ffffff;
  margin: 0;
}

.header p {
  font-size: 1rem;
  color: #bbbbbb;
  margin: 0;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #ffffff;
  font-size: 0.9rem;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
}

.form-group textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
  resize: vertical;
}

.form-group button {
  width: 100%;
  padding: 0.75rem;
  border: none;
  border-radius: 6px;
  background-color: #4caf50;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.form-group button:hover {
  background-color: #45a049;
}

.error-message {
  color: #ff4d4d;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

.success-message {
  color: #4caf50;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

@media (max-width: 600px) {
  .container {
    padding: 1rem;
  }

  .form-group input,
  .form-group textarea {
    font-size: 0.9rem;
  }

  .form-group button {
    font-size: 0.9rem;
  }
}

前端比较简单,前端部分的实现就不进行详细讲解了。

8. 运行与验证

8.1 启动后端

打开一个终端窗口:

cd backend
# 1. 安装依赖
pip install -r requirements.txt

# 2. 设置 API Key (重要!)
# 编辑 .env 文件,填入你的 DeepSeek API Key
# DEEPSEEK_API_KEY=sk-... 

# 3. 启动服务器
python server.py
# 服务将运行在 http://0.0.0.0:8000

8.2 启动前端

打开一个新的终端窗口:

cd frontend
# 1. 安装依赖
npm install

# 2. 启动开发服务器
npm run dev

访问前端地址,你就可以看到一个简洁的聊天界面。

image.png

当你输入问题并点击发送时,请求会经过: 前端 -> FastAPI -> LangChain -> DeepSeek API -> 返回结果

9. 总结

通过本文,我们完成了一个最小可行性产品(MVP)。从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。

这个项目虽然简单,但它包含了一个 AI 应用的完整骨架。你可以在此基础上扩展更多功能,例如添加对话历史记忆 (Memory)  或 RAG (知识库检索)

接下来我将继续输出更多 AI 全栈的相关知识,欢迎大家关注本栏目。我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

❌