通过综合实战:构建 Mini Search Agent
通过这个项目,你可以掌握以下核心能力:
AI 架构设计:理解 RAG、ReAct、Function Calling 的核心原理。
向量数据库:熟练使用 PostgreSQL + pgvector 进行语义检索和元数据过滤。
后端开发:Node.js + Express + OpenAI SDK,实现流式 SSE 输出。
前端交互:React + Tailwind + ReactMarkdown,打造专业的 Agent 对话界面。
工程化思维:相似度阈值防御、引用来源追溯、思考过程可视化。
知识库
针对码头知识库(通常包含大量的操作手册、安全规范、设备参数等结构化或非结构化文档),单纯的“联网搜索”是不够的。你需要引入 RAG (Retrieval-Augmented Generation,检索增强生成) 架构。
但在实现这个Agent之前,我们需要先解决一个核心问题:数据怎么进去?
大模型无法直接“读”你硬盘里的 PDF 或 Word 文档。在 Agent 能检索之前,我们必须经历一个 ETL (Extract, Transform, Load) 的过程。
作为学习阶段,可以将所有的PDF/Word文档的内容提取出来,切分成小的文本块(Chunks),计算向量(Embeddings)并存入向量数据库。
所以,构建码头知识库 Agent 的核心,其实是构建一个向量检索管道。
现在,假设你已经完成了数据入库,接下来我们要修改 Agent 的“手脚”。这里有一个关键的工程细节:用户的问题往往是模糊的。比如用户问:“吊车坏了怎么办?”直接拿去向量库里搜,可能搜出来一堆“吊车保养记录”。
但如果我们先让 LLM 把这个问题改写成:“吊车常见故障排查流程”或“起重机维修操作规范”,搜索精度会大幅提升。
这就引出了我们在编写 queryPortKB 这个工具函数之前,需要在 System Prompt 里做的一点小手脚。
为了让 Agent 更精准地检索码头知识库,在 System Prompt 中定义“工具调用策略”时,你需要要求模型在调用工具前,先将用户的口语化问题重写为包含专业术语的,适合搜索引擎的查询语句。这就是 Query Rewriting(查询重写)
在码头场景下,这个细节至关重要:
用户问:“那个吊集装箱的大家伙不动了咋整?”
直接搜索:可能匹配不到任何文档,因为手册里写的是“起重机”、“岸桥”、“故障排查”。
重写后:“岸桥(STS)起重机 无法启动 故障排查流程”
搜索结果:精准命中《港口起重机械应急维修手册》第 3 章。
实战架构:Mini Search Agent 蓝图
1. 数据层 (Data Pipeline) - 离线处理
Extract: 使用库 (如
pdf-parse,mammoth) 提取 PDF/Word 文本。Chunk: 按段落或固定字符数(如 500 字)切分,保留重叠(Overlap)以防切断语义。
Embedding: 调用 Embedding API (如 OpenAI
text-embedding-3-small) 将文本块转为向量数组。Store: 存入向量数据库 (如 Pinecone, Milvus, 或 pgvector)。
Metadata: 记得存上
{ source: "操作手册.pdf", page: 42, category: "安全规范" },方便后续过滤。
2. 工具层 (Tool Definition)
定义你的专属工具 queryPortKB:
TYPESCRIPT
const tools = [{
type: "function",
function: {
name: "queryPortKB",
description: "检索码头内部知识库,包括操作手册、安全规范、设备参数。适用于回答具体的业务流程或技术参数问题。",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "经过重写的、包含专业术语的搜索查询语句。例如将'吊车坏了'重写为'起重机故障排查'"
},
category: {
type: "string",
enum: ["safety", "operation", "maintenance"],
description: "可选的知识分类,用于缩小检索范围"
}
},
required: ["query"]
}
}
}];3. 大脑层 (System Prompt 升级)
在你的 System Prompt 中加入这条“铁律”:
“你是一个码头作业助手。当用户询问具体操作流程、安全规范或设备参数时,必须优先调用
queryPortKB工具。
在调用前,请将用户的口语化描述重写为专业的工程术语。
如果知识库中没有相关信息,请明确告知用户‘未找到相关规范’,严禁编造。”
4. 循环层 (Reasoning Loop)
这与你刚刚掌握的 while 循环逻辑完全一致,只是执行函数变了:
TYPESCRIPT
while (iteration < max_iterations) {
const response = await llm.chat({ messages: history, tools });
if (response.tool_calls) {
// 执行专属工具
const result = await queryPortKB(response.tool_calls[0].arguments.query);
// 回传结果 (关键:带上 tool_call_id)
history.push({ role: "tool", content: result, tool_call_id: response.tool_calls[0].id });
} else {
// 模型已汇总知识,输出最终答案
return response.content;
}
}向量数据库设计
作为 React 开发者,你可以把向量数据库想象成一个“支持语义搜索的特殊表”。
传统的 SQL 表是 WHERE id = 1(精确匹配),而向量表是 ORDER BY embedding <-> query_embedding LIMIT 5(相似度匹配)。
在码头场景下,我们不仅要搜得“准”(语义相似),还要搜得“对”(符合业务范畴)。比如用户问“吊车维修”,我们不能搜出“食堂菜谱”,哪怕它们的向量在某些奇怪的空间里离得很近。
因此,设计 Schema 的核心在于:向量列(用于相似度)+ 元数据列(用于过滤)。
知识库Schema设计(以pgvector为例)
1. 核心表结构
SQL
-- 1. 启用 pgvector 插件
CREATE EXTENSION IF NOT EXISTS vector;
-- 2. 创建表
CREATE TABLE port_knowledge_base (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536),
doc_type VARCHAR(50),
source_file VARCHAR(255),
page_num INTEGER,
security_level VARCHAR(20) DEFAULT 'public',
created_at TIMESTAMP DEFAULT NOW()
);
-- 3. 创建索引
CREATE INDEX ON port_knowledge_base USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX idx_doc_type ON port_knowledge_base(doc_type);2. 查询逻辑 (SQL 即代码)
现在,Agent 在执行 queryPortKB 工具时,生成的 SQL 大概是这样的:
SQL
SELECT
content,
source_file,
page_num,
1 - (embedding <=> $1) AS similarity_score -- 计算相似度 (0~1)
FROM port_knowledge_base
WHERE
doc_type = ANY($2) -- 过滤类型: 比如只查 ['safety', 'operation']
AND security_level = $3 -- 过滤权限: 比如当前用户只能查 'public'
ORDER BY
embedding <=> $1 -- 按相似度排序 (越近越小)
LIMIT 5; -- 取前 5 个最相关的片段这里有两个工程细节需要注意:
混合过滤策略:
先
WHERE(元数据过滤,走 B-Tree 索引,极快)后
ORDER BY(向量计算,走 IVF 索引,较快)这种组合能保证既“准”又“快”。
阈值截断 (Thresholding):
有时候搜出来的结果相似度很低(比如
< 0.5),说明知识库里根本没有答案。此时应该在代码层直接返回“未找到相关规范”,而不是把垃圾信息喂给 LLM 让它产生幻觉。
分块策略(Chunking Strategy)
Schema 定好了,但还有一个前置问题:“多大的一块文本算一个 Chunk?”
在码头场景下,这直接影响检索质量:
Chunk 太大 (如 2000 字):包含太多杂音,向量表示不纯粹,匹配度下降。
Chunk 太小 (如 50 字):丢失上下文。比如“第一步:检查电源。第二步:...”如果切断了,模型就不知道这是什么的步骤。
按语义结构切分:优先按章节标题,段落换行切分,并保持完整的列表项在一个chunk内,设置重叠越10%-20%。这样切分可以:
保持上下文完整性:
想象一下“紧急制动流程”:
按下红色按钮
拉下阀门 A
通知控制室
如果用 B (固定切断),很可能第 1 步在一个 Chunk,第 2-3 步在另一个。用户搜“紧急制动”,可能只拿到半截指令,这在工业场景下是致命的。
A 策略 会识别列表结构,确保这 3 步作为一个整体被检索。
重叠 (Overlap) 的妙用:
设置 10-20% 的重叠(比如前一个 Chunk 的末尾 100 字,也是下一个 Chunk 的开头),是为了解决“边界效应”。
即使切分点稍微偏了一点,关键信息也会因为重叠而被相邻的 Chunk 捕获,保证“网不漏鱼”。
实现一个“智能切分器”
作为开发者,我们直接看代码。这里有一个基于 Node.js 的简单实现思路(你可以用 langchain 或手写):
TYPESCRIPT
interface Chunk {
content: string;
metadata: {
sourceFile: string;
pageNum: number;
docType: 'safety' | 'operation' | 'maintenance';
};
}
function smartChunk(text: string, fileName: string): Chunk[] {
const chunks: Chunk[] = [];
// 1. 按章节标题分割 (Regex 匹配 "## 第 X 章" 或 "1.2 安全规范")
const sections = text.split(/(?=^##\s|^\d+\.\d+\s)/m);
sections.forEach(section => {
// 2. 如果章节太长 (>1000 字),再按段落二次分割
if (section.length > 1000) {
const paragraphs = section.split('\n\n');
let currentChunk = "";
paragraphs.forEach(p => {
if ((currentChunk + p).length > 800) {
// 保存当前块
chunks.push({
content: currentChunk,
metadata: { sourceFile: fileName, pageNum: extractPage(section), docType: inferType(section) }
});
// 保留 20% 重叠
currentChunk = currentChunk.slice(-160) + p;
} else {
currentChunk += p + "\n\n";
}
});
// 收尾
if (currentChunk.trim()) {
chunks.push({
content: currentChunk,
metadata: { sourceFile: fileName, pageNum: extractPage(section), docType: inferType(section) }
});
}
} else {
// 短章节直接存
chunks.push({
content: section,
metadata: { sourceFile: fileName, pageNum: extractPage(section), docType: inferType(section) }
});
}
});
return chunks;
}
关键点解析:
inferType(section): 可以根据章节关键词自动打标。比如包含“禁止”、“必须”的归为safety;包含“操作步骤”的归为operation。这能帮你自动填充 Schema 里的doc_type字段。重叠处理:
currentChunk.slice(-160)确保了语境的连续性。
🎓 你的 RAG Agent 数据层已就绪
到现在为止,你已经完整设计了码头知识库 Agent 的数据基础设施:
Schema 设计:
vector+metadata组合,支持高效过滤。索引优化:IVFFlat 加速向量搜索,B-Tree 加速权限过滤。
ETL 策略:智能切分,保持语义完整,自动打标。
现在,数据已经入库,Schema 已经建好。接下来就是让 Agent “动起来” 的时刻了。
当用户问:“岸桥突然断电了怎么处理?”
你的系统将会:
Rewrite: LLM 将问题重写为“岸桥起重机 突发断电 应急处理流程”。
Filter: SQL
WHERE doc_type = 'safety' OR 'operation'。Search:
ORDER BY embedding <=> query找出最相似的 3 个 Chunk。Synthesize: 把这 3 个 Chunk 塞给 LLM,让它生成最终的自然语言回答。
开始动手实施
环境搭建:
BASHnpm install pg openai langchain # 初始化 pgvector 数据库编写 ETL 脚本:
读取一个 PDF 示例文件。
实现
smartChunk函数。调用 OpenAI Embedding API 并存入 Postgres。
升级 Agent 代码:
修改
tools定义,加入queryPortKB。更新 System Prompt,加入“重写查询”和“引用来源”的要求。
在
while循环中对接真实的 SQL 查询。
💻 第一步:初始化后端环境
在 VS Code 中打开终端,我们先搭建最核心的 server 部分。你需要安装几个关键依赖:
BASH
mkdir port-agent-project && cd port-agent-project
mkdir server && cd server
npm init -y
npm install express openai pg dotenv cors
npm install -D typescript @types/node @types/express @types/pg nodemon ts-node
npx tsc --init
接下来,最关键的一步是配置 .env 文件。请在 server/ 目录下创建 .env,并填入以下内容(这是你的“钥匙”):
ENV
# OpenAI API (或其他兼容模型)
OPENAI_API_KEY=sk-...
# PostgreSQL 连接串 (包含 pgvector)
# 格式: postgres://用户:密码@主机:端口/数据库名
DATABASE_URL=postgres://postgres:password@localhost:5432/port_agent_db
# 安全配置
PORT=3001
🧠 核心代码实战:tools.ts
现在,让我们写出你刚才设计的第一个核心功能:queryPortKB。
请在 server/src/agent/tools.ts 中创建这个文件。这是一个真正的函数,它会被你的 Agent 循环调用。
TYPESCRIPT
/*
* @Author: 刘书记
* @Date: 2026-04-01 17:18:00
* @Description: 努力搬砖的小花花
*/
import { Pool } from 'pg';
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
// 初始化客户端
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL // 支持中转 API
});
// 1. 定义工具的描述 (用于 Function Calling)
export const queryPortKBTool = {
type: "function" as const,
function: {
name: "queryPortKB",
description: "检索码头内部知识库,包括操作手册、安全规范。适用于回答具体的业务流程或技术参数问题。",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "经过重写的、包含专业术语的搜索查询语句。例如将'吊车坏了'重写为'起重机故障排查'"
},
docType: {
type: "string",
enum: ["safety", "operation", "maintenance"],
description: "可选的文档类型过滤"
}
},
required: ["query"]
}
}
};
// 2. 真正的执行函数
export async function executeQueryPortKB(args: { query: string; docType?: string }) {
const { query, docType } = args;
console.log(`🔍 正在检索知识库: "${query}" (类型: ${docType || '全部'})...`);
try {
// A. 生成查询向量
const embeddingResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
const vector = embeddingResponse?.data?.[0]?.embedding;
if (!vector || vector.length === 0) {
console.log("⚠️ 向量生成结果为空,跳过知识库检索");
return "当前问题未能生成有效检索向量,请稍后重试或换个问法。";
}
// B. 构建 SQL (带元数据过滤)
// 注意:$1 是向量,$2 是 docType (如果有)
let sql = `
SELECT content, source_file, page_num, 1 - (embedding <=> $1::vector) AS similarity
FROM port_knowledge_base
WHERE 1=1
`;
const vectorLiteral = `[${vector.join(",")}]`;
const values: any[] = [vectorLiteral];
if (docType) {
sql += ` AND doc_type = $2`;
values.push(docType);
}
// 按相似度排序,取前 5 个
sql += ` ORDER BY embedding <=> $1::vector LIMIT 5`;
const res = await pool.query(sql, values);
// C. ⚠️ 关键防御:相似度阈值检查
if (res.rows.length === 0 || res.rows[0].similarity < 0.4) {
console.log('⚠️ 未找到高相关性的文档 (最高分:', res.rows[0]?.similarity?.toFixed(3) || 0, ')');
return "未在码头知识库中找到与当前问题高度相关的文档。请尝试换个问法或确认问题是否属于业务范围。";
}
// D. 格式化返回结果 (带引用信息)
const context = res.rows.map((row, idx) =>
`[片段 ${idx + 1} 来源:${row.source_file} 第${row.page_num}页 | 相似度: ${row.similarity.toFixed(3)}]\n${row.content}`
).join('\n\n');
console.log(`✅ 找到 ${res.rows.length} 个相关片段,最高相似度: ${res.rows[0].similarity.toFixed(3)}`);
return context;
} catch (error) {
console.error("❌ DB Query Error:", error);
throw new Error(`知识库检索失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}🧪 下一步:单元测试
请在 server/src/ 创建 test-tool.ts:
TYPESCRIPT
import { executeQueryPortKB } from './agent/tools';
async function testTool() {
try {
// 模拟一个用户查询 (假设你库里已经有数据)
// 如果库是空的,这个测试会返回 "未找到...",那也是正常的
const result = await executeQueryPortKB({
query: "起重机 紧急制动 操作流程",
docType: "operation"
});
console.log("\n--- 工具返回结果 ---");
console.log(result);
} catch (err) {
console.error(err);
}
}
testTool();运行它:
BASH
npx ts-node src/test-tool.ts请创建一个 server/src/seed-data.ts 脚本。它会:
调用 OpenAI Embedding API 生成真实的向量。
插入到数据库中。
让你立刻就能测试相似度搜索。
🚀 数据种子脚本 (seed-data.ts)
在 server/src/ 目录下创建此文件:
TYPESCRIPT
import { Pool } from 'pg';
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
});
const testData = [
{
content: "紧急制动操作流程:1. 立即按下红色紧急停止按钮。2. 将主控制器手柄归零。3. 通过对讲机通知控制室。4. 等待维修人员检查液压系统压力。",
doc_type: "operation",
source_file: "起重机操作手册_v2.3.pdf",
page_num: 42
},
{
content: "安全规范第 5 条:所有操作人员必须佩戴安全帽和反光背心。严禁在吊臂下方站立或通行。风速超过 12m/s 时必须停止作业。",
doc_type: "safety",
source_file: "港口安全红宝书_2024.pdf",
page_num: 15
},
{
content: "日常维护检查表:每日开班前需检查钢丝绳磨损情况、制动器灵敏度、限位开关是否正常工作。发现异常立即上报。",
doc_type: "maintenance",
source_file: "设备维护保养指南.pdf",
page_num: 8
},
{
content: "故障代码 E-03 表示液压系统压力过低。处理方法:检查油箱液位,补充液压油;检查油泵皮带是否松动。",
doc_type: "maintenance",
source_file: "起重机故障代码速查表.pdf",
page_num: 3
}
];
async function seed() {
console.log('🌱 开始插入测试数据...');
for (const item of testData) {
try {
// 1. 生成向量
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: item.content,
});
const vector = embedding.data[0].embedding;
// 2. 插入数据库
await pool.query(
`INSERT INTO port_knowledge_base (content, embedding, doc_type, source_file, page_num)
VALUES ($1, $2, $3, $4, $5)`,
[item.content, vector, item.doc_type, item.source_file, item.page_num]
);
console.log(`✅ 已插入: "${item.content.substring(0, 20)}..." (${item.doc_type})`);
} catch (err) {
console.error(`❌ 插入失败:`, err);
}
}
console.log('\n🎉 数据种子完成!现在可以运行 test-tool.ts 进行测试了。');
await pool.end();
}
seed();
▶️ 运行脚本
在终端执行:
BASH
npx ts-node src/seed-data.ts
如果一切顺利,你会看到 4 条 ✅。
然后,再次运行之前的测试:
BASH
npx ts-node src/test-tool.ts
这次你应该能看到真实的检索结果了!
🧠 下一步:整合进 Agent 循环
现在你的“手” (queryPortKB) 已经能工作了。接下来,我们要把它装到“大脑” (LLM) 上,让模型知道在什么时候调用它。
我们需要修改 System Prompt 和 Agent 循环逻辑。
1. 更新 System Prompt
在 server/src/agent/systemPrompt.ts (或者直接写在主文件里),定义你的 Agent 人设:
TYPESCRIPT
export const SYSTEM_PROMPT = `
你是一个专业的码头作业助手,专门回答关于起重机操作、安全规范和设备维护的问题。
**核心指令:**
1. **优先检索**:当用户询问具体的操作流程、故障代码、安全规定时,**必须**优先调用 \`queryPortKB\` 工具检索知识库。严禁凭空编造答案。
2. **查询重写**:在调用工具前,请将用户的口语化问题重写为专业的工程术语。
- 用户问:"吊车坏了咋办?" -> 你调用工具时用:"起重机 常见故障 排查流程"
- 用户问:"那个红线是啥?" -> 你调用工具时用:"安全规范 警戒线 定义"
3. **引用来源**:回答时必须注明信息来源,例如:“根据《起重机操作手册》第 42 页...”。
4. **边界控制**:如果工具返回“未找到相关文档”,请诚实告知用户,不要强行回答。
`;
2. 整合到主循环
现在,我们来写最终的 server/src/index.ts (或 agent-loop.ts)。这是整个应用的大脑。
TYPESCRIPT
/*
* @Author: 刘书记
* @Date: 2026-04-01 18:26:41
* @Description: 努力搬砖的小花花
*/
import express from 'express';
import cors from 'cors';
import OpenAI from 'openai';
import dotenv from 'dotenv';
import { queryPortKBTool, executeQueryPortKB } from './agent/tools';
import { SYSTEM_PROMPT } from './agent/systemPrompt';
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
});
app.post('/api/chat', async (req, res) => {
const userMessage = req.body?.message ?? req.body?.maessage;
if (typeof userMessage !== 'string' || userMessage.trim().length === 0) {
return res.status(400).json({ error: "请求体缺少有效的 message 字段(字符串)" });
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const inputHistory = Array.isArray(req.body?.history) ? req.body.history : [];
const chatHistory: any[] = [...inputHistory, { role: 'user', content: userMessage.trim() }];
let iteration = 0;
const maxIterations = 5;
const sources: Array<{ file: string; page: string }> = [];
try {
while (iteration < maxIterations) {
res.write(`data: ${JSON.stringify({ type: 'thinking', content: '正在分析用户意图...' })}\n\n`);
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...chatHistory],
tools: [queryPortKBTool],
stream: true
});
let fullContent = "";
const toolCallsMap = new Map<number, any>();
for await (const chunk of response) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
if (delta.content) {
fullContent += delta.content;
res.write(`data: ${JSON.stringify({ type: 'content', content: delta.content })}\n\n`);
}
if (delta.tool_calls) {
for (const partialToolCall of delta.tool_calls) {
const idx = partialToolCall.index ?? 0;
const current = toolCallsMap.get(idx) ?? {
id: partialToolCall.id ?? "",
type: 'function',
function: { name: "", arguments: "" }
};
if (partialToolCall.id) current.id = partialToolCall.id;
if (partialToolCall.function?.name) current.function.name = partialToolCall.function.name;
if (partialToolCall.function?.arguments) {
current.function.arguments += partialToolCall.function.arguments;
}
toolCallsMap.set(idx, current);
}
}
}
const toolCalls = Array.from(toolCallsMap.values());
// 没有工具调用,说明可以直接结束
if (toolCalls.length === 0) {
if (sources.length > 0) {
res.write(`data: ${JSON.stringify({ type: 'sources', data: sources })}\n\n`);
}
res.write(`data: [DONE]\n\n`);
res.end();
return;
}
// 关键:先把 assistant tool_calls 放入历史,再追加 tool 消息
chatHistory.push({
role: 'assistant',
content: fullContent || "",
tool_calls: toolCalls
});
for (const toolCall of toolCalls) {
if (toolCall.type !== 'function' || toolCall.function?.name !== 'queryPortKB') {
continue;
}
let args: { query: string; docType?: string };
try {
args = JSON.parse(toolCall.function.arguments);
} catch {
res.write(`data: ${JSON.stringify({ type: 'error', content: '工具参数解析失败,已跳过。' })}\n\n`);
continue;
}
res.write(`data: ${JSON.stringify({
type: 'tool_start',
content: `正在检索知识库:${args.query}...`
})}\n\n`);
const toolResult = await executeQueryPortKB(args);
const sourceRegex = /\[片段\s+\d+\s+来源:(.+?)\s+第(\d+)页/g;
for (const match of toolResult.matchAll(sourceRegex)) {
const file = match[1];
const page = match[2];
if (file && page) {
sources.push({ file, page });
}
}
chatHistory.push({
role: 'tool',
tool_call_id: toolCall.id,
content: toolResult
});
}
iteration++;
}
res.write(`data: ${JSON.stringify({ type: 'error', content: '达到最大推理轮数,已停止。' })}\n\n`);
res.write(`data: [DONE]\n\n`);
res.end();
} catch (error) {
console.error(error);
res.write(`data: ${JSON.stringify({ type: 'error', content: '出错了...' })}\n\n`);
res.end();
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`🚀 码头 Agent 服务已启动 http://localhost:${PORT}`);
});🏁 最后的测试
启动服务:
BASHnpx ts-node src/index.ts使用 Postman 或 curl 测试:
BASHcurl -X POST http://localhost:3001/api/chat \ -H "Content-Type: application/json" \ -d "{\"message\": \"吊车紧急制动怎么做?\"}"
预期结果:

