如果跟着我完成了前面的一些学习,相信你也对ai有一定程度的了解了,话不多说我们下一步继续学手搓一个简单的ReAct Agent,如果你不了解什么是ReAct的话可以百度或者问一下AI,相信AI可以给你很详细的解答。

起步

既然是手搓的话,咱们就先不用前面用到的Langchain.js了(手搓完再展示Langchain.js v1.0的createAgent),这里使用一个非常轻便的库:xsai,相对于Langchain.js动不动几十MB的安装依赖,xsai就要小很多了

image.png

然后js运行时咱们选非常好用的Bun,因为内置了很多好用的工具,比如Monorepo、env管理等等

然后模型选择,既然叫ReAct,那么我们需要一个能够推理思考的模型,当然你也可以写提示词让非推理模型生成推理链,不过现在是5202年末,各大厂商都有推出非常牛逼的推理模型,那么咱们简单点选个推理模型就好了,这里我选择了Qwen3-8B,使用ollama本地运行(其实是最近换了新显卡发现本地跑小模型也挺快的),当然也可以选择一些云计算平台,比如火山、百炼等等,都有几十上百万的免费token,要是用完了想白嫖也可以选择硅基流动的免费模型!

image.png

除此之外也没啥多余的东西了,那么开始操作吧

1
2
3
4
5
6
7
8
9
10
11
12
13
bun init                                                                 

✓ Select a project template: Blank

+ .gitignore
+ index.ts
+ tsconfig.json (for editor autocomplete)
+ README.md

To get started, run:

bun run index.ts

1
bun add @xsai/shared-chat @xsai/tool @xsai/utils-chat zod

编写代码

为了方便演示,代码就不拆分文件了,直接写到底,并用简单说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import {
chat,
type AssistantMessage,
type FinishReason,
type Message,
type Tool,
type Usage,
} from "@xsai/shared-chat";
import { tool } from "@xsai/tool";
import { message } from "@xsai/utils-chat";
import * as z from "zod";

//#region openai 返回结构
export interface Choice {
finish_reason: FinishReason;
index: number;
message: AssistantMessage;
}

export interface ChatCompletionsResponse {
choices: Choice[];
created: number;
id: string;
model: string;
object: "chat.completion";
system_fingerprint: string;
usage: Usage;
}
//#endregion

// 方便生成message
export const assistant = message.assistant;
export const system = message.system;
export const user = message.user;

// 用来创建agent
export function createAgent(options: {
provider: {
baseURL: string;
apiKey: string;
model: string;
};
tools?: Tool[];
}) {
// call:让agent开始工作
async function call(
messages: Message[],
callOptions: { maxRoundTrip: number } = { maxRoundTrip: 10 }
) {
let count = 0;
// 限制一下agent最大运行步数
while (count < callOptions.maxRoundTrip) {
const res = await chat({
...options.provider,
baseURL: options.provider.baseURL,
model: options.provider.model,
messages,
tools: options.tools,
});
const cmplResp = (await res.json()) as ChatCompletionsResponse;
console.log(JSON.stringify(cmplResp.choices[0], null, 2));
const toolCalls = cmplResp.choices[0]?.message.tool_calls;
// 判断是否函数调用,如果没有函数调用说明结束工作,已经得到结果
if (!toolCalls?.length) {
return cmplResp;
}
// 将tool_calls加入到messages上下文中,方便ai判读已经调用过说明函数
messages.push(assistant(toolCalls[0]));
for (const choice of cmplResp.choices) {
if (!choice.message.tool_calls) {
continue;
}
for (const toolCall of choice.message.tool_calls) {
// 找到tool
const foundTool = options.tools?.find(
(tool) => tool.function.name === toolCall.function.name
);
if (!foundTool) {
continue;
}
// 调用tool拿到结果
const invokeResult = await foundTool.execute(
JSON.parse(toolCall.function.arguments || "{}"),
{
messages,
toolCallId: toolCall.id,
}
);
// 将结果加入到上下文中
messages.push({
role: "tool",
content:
invokeResult === "string"
? invokeResult
: JSON.stringify(invokeResult),
tool_call_id: toolCall.id,
});
}
}
// 完成一次函数调用
count++;
}
}
return { call };
}

async function main() {
// 创建工具
const getCity = await tool({
name: "getCity",
description: "Get the user's city",
execute: () => "广州",
parameters: z.object({}),
});
const getCityCode = await tool({
name: "getCityCode",
description: "Get the user's city code with search",
execute: () => "Guangzhou",
parameters: z.object({
location: z
.string()
.min(1)
.describe("Get the user's city code with search"),
}),
});
const getWeather = await tool({
name: "getWeather",
description: "Get the city code weather",
execute: ({ cityCode }) => ({
city: `广州`,
cityCode,
weather: "sunny",
degreesCelsius: 26,
}),
parameters: z.object({
cityCode: z.string().min(1).describe("Get the city code weather"),
}),
});
// 创建agent
const { call } = createAgent({
provider: {
baseURL: "http://localhost:11434/v1",
apiKey: "unused",
model: "qwen3:8b",
},
tools: [getCity, getCityCode, getWeather],
});
// 运行
const res = await call([
system(
"我是一名乐于助人的助手,负责为用户提供所需信息。用户可能提出任何问题,请识别用户的需求,并选用合适的工具来获取必要信息。"
),
user("今天天气怎么样?"),
]);
// 观察执行结果
console.log(res?.choices[0]?.message.content);
}

main();

执行一下,查看结果:

1
2
3
bun index.ts

今天广州的天气是晴天,气温28摄氏度。

是不是很简单通俗易懂。

邪修(bushi)快捷办法

当然你可以参考上面的办法简单封装一下,或者,也不是不可以用别人写好的办法,比如langchain.js的办法

1
bun add langchain @langchain/openai

编写代码!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { ChatOpenAI } from "@langchain/openai";
import { createAgent, tool } from "langchain";
import * as z from "zod";

const getCity = tool(() => "广州", {
name: "getCity",
description: "Get the user's city",
schema: z.object({}),
});

const getCityCode = tool(() => "Guangzhou", {
name: "getCityCode",
description: "Get the user's city code with search",
schema: z.object({
location: z
.string()
.min(1)
.describe("Get the user's city code with search"),
}),
});
const getWeather = tool(
({ cityCode }) => ({
city: `广州`,
cityCode,
weather: "sunny",
degreesCelsius: 28,
}),
{
name: "getWeather",
description: "Get the city code weather",
schema: z.object({
cityCode: z.string().min(1).describe("Get the city code weather"),
}),
}
);

const agent = createAgent({
model: new ChatOpenAI({
configuration: {
baseURL: "http://localhost:11434/v1",
},
apiKey: "unused",
model: "qwen3:8b",
}),
tools: [getCity, getCityCode, getWeather],
systemPrompt: "You are a helpful assistant.",
});

const result = await agent.invoke({
messages: [{ role: "user", content: "今天天气怎么样" }],
});

console.log(result.messages.at(-1)?.content);
1
2
bun langchain-agent.ts                                                                                   
今天广州的天气晴朗,气温28摄氏度。

嗯,很简单粗暴,很邪修(

你学废了吗!

原文:Agent architectures

许多LLM(大型语言模型)应用程序在LLM调用之前和/或之后执行特定的步骤控制流。例如,RAG(检索增强生成)会检索与问题相关的文档,并将这些文档传递给LLM,以便为模型的回答提供依据。

阅读全文 »

代数数据类型 (Algebraic Data Type) 、 广义代数数据类型 (Generalized Algebriac Data Type) 、余代数数据类型 (Coalgebraic Data Type)

https://github.com/asukaminato0721/magic-in-ten-mins-ts/blob/master/doc/ADT.md

https://github.com/asukaminato0721/magic-in-ten-mins-ts/blob/master/doc/GADT.md

https://github.com/asukaminato0721/magic-in-ten-mins-ts/blob/master/doc/CoData.md

阅读全文 »

原文:Modern Node.js Patterns for 2025,翻译来自DeepSeek-R1

Node.js 自诞生以来经历了显著的变革。如果您使用 Node.js 已有数年,您很可能亲身见证了它的演进——从重度依赖回调、CommonJS 主导的环境,发展到如今基于标准的、简洁的开发体验。这些变化不仅仅是表面的;它们代表了我们在服务器端 JavaScript 开发方法上的根本性转变。现代 Node.js 拥抱 Web 标准,减少外部依赖,并提供更直观的开发体验。让我们探索这些转变,并理解它们为何对您 2025 年的应用程序至关重要。

阅读全文 »

前面学了如何构建提示词、加载文档/网页内容、检索、嵌入等等,实现简单的RAG,下面再来继续学一下函数调用,让ai调用代码能力实现对ai能力的增强

阅读全文 »

上一篇讲了基本的调用LLM,还有简单或者灵活的去使用模板生成提示词,以及使用提示词+解析器实现结构化的输出,这一节介绍一下Loader、Embedding和搭建向量数据库,最后实现一个简单的RAG

阅读全文 »

前言

最近在学RAG,学到Embedding部分,想搭建一个向量库去存数据,看了一圈网上以及问大佬,基本都是推荐milvus这个数据库,所以就开始想搭建一下玩玩。

部署milvus

看官方的部署文档有三种部署方式,轻量、单机、集群,其中轻量的限制linux和mac,我电脑是windows,只有虚拟机linux,而且集群的话又挺麻烦的,就选了单机版。单机版又分了单镜像和compose,单镜像还得用一个脚本来启动,我个人不怎么喜欢脚本(可能是因为不会吧),就选了compose了。

1
2
3
4
5
# Download the configuration file 
$ wget https://github.com/milvus-io/milvus/releases/download/v2.5.6/milvus-standalone-docker-compose.yml -O docker-compose.yml

# Start Milvus
$ sudo docker compose up -d

非常熟悉的docker compose,不过我启动完成后在docker desktop一看竟然没发现刚刚启动的容器,想了一下估计是因为我用sudo启动的,而docker desktop是非root启动,然后他们就被隔离开了导致看不到。

那之后删掉之前的容器,重新在当前用户启动

1
2
3
sudo docker compose down -v

docker compose up -d

然后发现怎么还得重新下镜像,原来不同用户连镜像都隔离开了,那好吧,我知道docker有个导出镜像的功能,就将那这几个镜像导出然后再在非root用户下导入(不知道还有没有更方便的办法,有懂的docker的大佬可以评论区告诉我拜托了)。

使用milvus

前面说到我是为了学ai才选了这个数据库,那么部署完成之后我就开始使用,我跑的是Langchain.js的一个demo代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Milvus } from "@langchain/community/vectorstores/milvus";
import { OllamaEmbeddings } from "@langchain/ollama";
import "dotenv/config";

const vectorStore = await Milvus.fromTexts(
[
"Tortoise: Labyrinth? Labyrinth? Could it Are we in the notorious Little\
Harmonic Labyrinth of the dreaded Majotaur?",
"Achilles: Yiikes! What is that?",
"Tortoise: They say-although I person never believed it myself-that an I\
Majotaur has created a tiny labyrinth sits in a pit in the middle of\
it, waiting innocent victims to get lost in its fears complexity.\
Then, when they wander and dazed into the center, he laughs and\
laughs at them-so hard, that he laughs them to death!",
"Achilles: Oh, no!",
"Tortoise: But it's only a myth. Courage, Achilles.",
],
[{ id: 2 }, { id: 1 }, { id: 3 }, { id: 4 }, { id: 5 }],
new OllamaEmbeddings({
model: "bge-m3:latest",
}),
{
collectionName: "goldel_escher_bach",
}
);

const response = await vectorStore.similaritySearch("scared", 2);
console.log(response);

简单说一下这段代码,就是通过Embedding模型将文本转成向量存到milvus数据库,

但是还没跑完发现代码报错了:Error: 14 UNAVAILABLE: No connection established. Last error: Failed to connect (2025-04-07T02:15:51.637Z)

怎么突然就连不上了,然后去docker desktop看了一下发现容器停掉了,点进去一看全是go的panic报错,看了一下panic前面的日志,提到:Resource requested is unreadable, please reduce your request rate

想了一会之后我记得milvus使用minio作为存储,然后去看了一下minio的日志,发现了确实是minio报错了:

1
2
3
4
5
6
7
8
9
10
11
2025-04-03 15:39:46 API: SYSTEM()
2025-04-03 15:39:46 Time: 07:39:46 UTC 04/03/2025
2025-04-03 15:39:46 DeploymentID: 755ca3ec-ee78-4b0d-b5bd-4796144ff205
2025-04-03 15:39:46 Error: write /minio_data/.minio.sys/tmp/d5510ac8-cbca-48af-9e21-98bd3dea7b66/xl.meta: invalid argument (*fs.PathError)
2025-04-03 15:39:46 6: internal/logger/logger.go:258:logger.LogIf()
2025-04-03 15:39:46 5: cmd/storage-errors.go:165:cmd.osErrToFileErr()
2025-04-03 15:39:46 4: cmd/xl-storage.go:2402:cmd.(*xlStorage).RenameData()
2025-04-03 15:39:46 3: cmd/xl-storage-disk-id-check.go:378:cmd.(*xlStorageDiskIDCheck).RenameData()
2025-04-03 15:39:46 2: cmd/erasure-object.go:774:cmd.renameData.func1()
2025-04-03 15:39:46 1: internal/sync/errgroup/errgroup.go:123:errgroup.(*Group).Go.func1()
2025-04-03 15:39:46 Waiting for all MinIO sub-systems to be initialized.. possible cause (Unable to initialize config system: migrateConfigToMinioSys: Storage resources are insufficient for the write operation .minio.sys/config/config.json)

然后去minio的github搜了一遍也没找到什么解决方法。我突然想到milvus的教程里面是用root用户启动的,我就想了一下用root试了一下。最后发现root启动是没有问题,上面代码也能跑通。那应该可能是权限之类的问题了。

解决问题

说起来我的docker使用经验也不多,我就去问了一些大佬,大佬看了一下docker-compose.yml之后给了两个方法,一个是特权模式,另一个是将volume的绑定目录改成命名卷。特权模式我试了一下没用,改成命名卷的方式通过问ai,完成修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
version: '3.5'

services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.18
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3

minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- milvus-minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3

standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.5.6
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"

networks:
default:
name: milvus

# 定义命名卷
volumes:
milvus-minio:

然后测了一下上面的代码,也能正常跑通了,minio和milvus也没有报错了。

这个问题根据github的issue所提,应该是只有在linux6.0之后的版本才会出现。

前文:手把手教你在浏览器和RUST中处理流式传输 提到如何简单的处理流式输出,但是后来发现这个写法有bug,下面讲解一下更好的写法

顺便补充一下,上一篇文章提到的IterableReadableStream来自@langchain/core,你可以这样导入使用:

1
import { IterableReadableStream } from '@langchain/core/utils/stream'

处理Event Stream

除了上一章的ndjson以外,最常用就是Event Stream了,包括OpenAi等一众ai服务提供商都会提供sse接口,并且以Event Stream的格式进行输出,先来看看ai是怎么理解Event StreamSSE的:

Server-Sent Events (SSE) ,一种基于 HTTP 的轻量协议,允许服务器向客户端推送实时数据流。

SSE 格式规范

  • 数据通过 HTTP 流式传输,内容类型为 text/event-stream

  • 每条事件由字段组成,用换行符分隔。字段包括:

    • data: 事件的具体内容(必填)。
    • event: 自定义事件类型(可选)。
    • id: 事件唯一标识符(可选)。
    • retry: 重连时间(毫秒,可选)。

示例

1
2
3
4
5
6
7
event: status_update
data: {"user": "Alice", "status": "online"}

id: 12345
data: This is a message.

retry: 3000

那再来看看ai输出的结果:

image.png

很标准的text/event-stream格式

使用langchainjs处理

你以为我要像上一篇一样开始手搓处理代码了吗,no no no,我们还是使用langchainjs进行处理,原因后面会提到。

这里推荐一个fetch封装工具:ofetch,一个类似axios的库,作用大家应该都懂了吧,这里我拿火山的接口来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// vite.config.js
export default defineConfig({
base: "/",
server: {
proxy: {
"/huoshan": {
changeOrigin: true,
ws: true,
secure: false,
target: "https://ark.cn-beijing.volces.com",
rewrite: (path) => path.replace(/^\/huoshan/, ""),
},
},
},
});

// vue.config.js
module.export = {
devServer: {
compress: false, // 重点!!!不关闭则有可能导致无法正常流式返回
proxy: {
'/huoshan': {
target: 'https://ark.cn-beijing.volces.com', // 代理
changeOrigin: true,
ws: true,
secure: false,
pathRewrite: {
'^/huoshan': '',
},
},
}
}
}

如果是webpack的话,一定要关闭devServercompress,不然会导致整个请求结束才返回,这样就不是流式输出了。

1
2
3
4
5
6
7
8
9
10
11
// request.js

import { ofetch } from "ofetch";

export const fetchRequest = ofetch.create({
baseURL: '/huoshan',
timeout: 60000,
onRequest({ options }) {
options.headers.set('Authorization', 'Bearer xxxxx') // 替换火山api的key
},
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { fetchRequest } from "./request";
import { convertEventStreamToIterableReadableDataStream } from "@langchain/core/utils/event_source_parse";

async function test() {
const res = await fetchRequest("/api/v3/chat/completions", {
responseType: "stream",
method: "post",
body: {
model: "deepseek-v3-250324",
messages: [
{
role: "user",
content: "你是谁?",
},
],
stream: true,
},
});
const stream = convertEventStreamToIterableReadableDataStream(res);
for await (const chunk of stream) {
console.log(chunk);
}
}
test()

image.png

返回正常,不过要注意,结尾有个[DONE],所以不能无脑反序列化,

1
2
3
4
5
for await (const chunk of stream) {
if (chunk !== '[DONE]') {
console.log(JSON.parse(chunk))
}
}

这样就拿到每个chunk了,当然你可以将test方法改成生成器,然后for里面yield JSON.parse(chunk)

为什么要用langchainjs封装好的方法处理

既然大家都知道流式输出是一个一个chunk的方式返回,那么是不是有可能一行的文本,拆分成两个chunk(在js看来是ArrayBuffer)?而一个utf8字符是定长的,可能是1-3字节,那是不是有可能在某个字符的时候,其中一部分字节拆分到一个chunk,然后剩下部分字节拆分到下一个chunk?

这样就会导致你在decode的时候发生报错,无法正常decode成文字,所以langchainjs的方法考虑到这个情况:

image.png

代码在:https://github.com/langchain-ai/langchainjs/blob/5100a9d0a1eda7b7998dd40624abdd3ff3002b36/langchain-core/src/utils/event_source_parse.ts#L88-L165

其他关注点

使用代理时需要注意

上面的webpack配置已经讲解了一下devServer应该怎么配置才能流式输出。还有就是使用nginx代理的时候也需要修改一下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 80;
location /huoshan/ {
# http1.1才支持长连接
proxy_http_version 1.1;
# 关闭代理缓冲
proxy_buffering off;
# 设置代理缓冲区大小
proxy_buffer_size 10k;
# 设置代理缓冲区数量和大小
proxy_buffers 4 10k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass https://ark.cn-beijing.volces.com/;
}


}

其实就是关闭一些代理缓冲,以及设置一下缓冲区,为什么要这样设置,这里有请懂nginx配置的大佬细说一下😜

0%