fix: 修复流式工具调用按 index 聚合(原按 id 导致延续分片参数残片化,表现为空参循环);新增 system.md 工具使用指南并作为系统提示词;read_excel 默认仅读前5行、数据操作统一走 DuckDB;工具轮数上限提升至20;新增工具流诊断脚本

This commit is contained in:
Cheney 2026-08-17 00:29:22 +08:00
parent e848ac91a7
commit d037f0d9cb
3 changed files with 162 additions and 10 deletions

View File

@ -229,7 +229,7 @@ async function execTool(name: string, args: any): Promise<{ ok: boolean; result:
const sheetName = args.sheet || wb.SheetNames[0]; const sheetName = args.sheet || wb.SheetNames[0];
const ws = wb.Sheets[sheetName]; const ws = wb.Sheets[sheetName];
const rows = utils.sheet_to_json(ws, { header: 1 }); const rows = utils.sheet_to_json(ws, { header: 1 });
const limit = args.limit || 200; const limit = args.limit || 5;
return { return {
ok: true, ok: true,
result: { totalRows: rows.length, columns: rows[0] || [], data: rows.slice(0, limit) }, result: { totalRows: rows.length, columns: rows[0] || [], data: rows.slice(0, limit) },
@ -347,7 +347,7 @@ const TOOL_DEFINITIONS = [
{ type: "function", function: { name: "move_file", description: "移动/重命名文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } }, { type: "function", function: { name: "move_file", description: "移动/重命名文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } },
{ type: "function", function: { name: "delete_file", description: "删除文件或空目录(需审批)", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } }, { type: "function", function: { name: "delete_file", description: "删除文件或空目录(需审批)", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } },
{ type: "function", function: { name: "execute_command", description: "执行 Shell 命令(需审批)", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } }, { type: "function", function: { name: "execute_command", description: "执行 Shell 命令(需审批)", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } },
{ type: "function", function: { name: "read_excel", description: "读取 Excel 返回 JSON默认前 200 行", parameters: { type: "object", properties: { path: { type: "string" }, sheet: { type: "string" }, limit: { type: "number" } }, required: ["path"] } } }, { type: "function", function: { name: "read_excel", description: "读取 Excel 返回 JSON默认前 5 行,仅用于理解数据结构;数据分析请用 load_excel + query_duckdb", parameters: { type: "object", properties: { path: { type: "string" }, sheet: { type: "string" }, limit: { type: "number" } }, required: ["path"] } } },
{ type: "function", function: { name: "write_excel", description: "将 JSON 数组写入 Excel", parameters: { type: "object", properties: { path: { type: "string" }, data: { type: "array" }, sheet: { type: "string" } }, required: ["path", "data"] } } }, { type: "function", function: { name: "write_excel", description: "将 JSON 数组写入 Excel", parameters: { type: "object", properties: { path: { type: "string" }, data: { type: "array" }, sheet: { type: "string" } }, required: ["path", "data"] } } },
{ type: "function", function: { name: "load_excel", description: "将 Excel 文件加载进 DuckDB 内存表以便用 SQL 分析数据处理。返回表名、列名与类型、前几行预览。加载后即可用 query_duckdb 执行 SQL筛选/聚合/排序/连接等)。", parameters: { type: "object", properties: { path: { type: "string", description: "Excel 文件路径" }, sheet: { type: "string", description: "工作表名,默认第一个" }, table: { type: "string", description: "目标表名,默认 excel_data" } }, required: ["path"] } } }, { type: "function", function: { name: "load_excel", description: "将 Excel 文件加载进 DuckDB 内存表以便用 SQL 分析数据处理。返回表名、列名与类型、前几行预览。加载后即可用 query_duckdb 执行 SQL筛选/聚合/排序/连接等)。", parameters: { type: "object", properties: { path: { type: "string", description: "Excel 文件路径" }, sheet: { type: "string", description: "工作表名,默认第一个" }, table: { type: "string", description: "目标表名,默认 excel_data" } }, required: ["path"] } } },
{ type: "function", function: { name: "query_duckdb", description: "在已加载的 DuckDB 表上执行 SQL 查询SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。", parameters: { type: "object", properties: { sql: { type: "string", description: "要执行的 SQL 语句" } }, required: ["sql"] } } }, { type: "function", function: { name: "query_duckdb", description: "在已加载的 DuckDB 表上执行 SQL 查询SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。", parameters: { type: "object", properties: { sql: { type: "string", description: "要执行的 SQL 语句" } }, required: ["sql"] } } },
@ -414,9 +414,11 @@ async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" |
if (delta.content) yield { type: "token", content: delta.content }; if (delta.content) yield { type: "token", content: delta.content };
if (delta.tool_calls) { if (delta.tool_calls) {
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {
const id = tc.id || `call_anon_${anonIdx++}`; // OpenAI 兼容流:首个分片带 id+name延续分片 id 为 null 且只带 arguments
if (!toolCalls.has(id)) toolCalls.set(id, { id, type: "function", function: { name: "", arguments: "" } }); // 必须按 index 聚合,否则每个分片被拆成独立调用导致参数残缺/为空
const acc = toolCalls.get(id); const key = (tc.index !== undefined ? String(tc.index) : tc.id) || `call_anon_${anonIdx++}`;
if (!toolCalls.has(key)) toolCalls.set(key, { id: tc.id || undefined, type: "function", function: { name: "", arguments: "" } });
const acc = toolCalls.get(key);
if (tc.function?.name) acc.function.name = tc.function.name; if (tc.function?.name) acc.function.name = tc.function.name;
if (tc.function?.arguments) acc.function.arguments += tc.function.arguments; if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
} }
@ -445,8 +447,15 @@ function sseSend(res: any, obj: any) {
// ---------- 对话处理(带工具循环) ---------- // ---------- 对话处理(带工具循环) ----------
let pendingApproval: { messages: ChatMsg[]; toolCall: any } | null = null; let pendingApproval: { messages: ChatMsg[]; toolCall: any } | null = null;
// 系统提示:定义助手身份 // 系统提示:优先读取项目根目录 system.md详细工具使用指南缺失时用内置提示
const SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手可以帮助处理文件读写、Excel、列出目录、执行命令需审批等任务。分析 Excel 数据时,优先用 load_excel 把文件加载进 DuckDB再用 query_duckdb 执行 SQL 完成筛选、聚合、统计、排序等;处理大量数据时不要逐行读取。请用简体中文友好、简洁地回答。"; const BASE_SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手,可以读写文件、处理 Excel、用 DuckDB 分析数据、列出目录、执行命令(需审批)等。请用简体中文友好、简洁地回答。";
let SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
try {
const md = readFileSync(resolve(import.meta.dir ?? ".", "system.md"), "utf-8").trim();
if (md) SYSTEM_PROMPT = md;
} catch {
/* 无 system.md 时使用内置提示 */
}
function withSystem(msgs: ChatMsg[]): ChatMsg[] { function withSystem(msgs: ChatMsg[]): ChatMsg[] {
if (msgs.length && msgs[0].role === "system") return msgs; if (msgs.length && msgs[0].role === "system") return msgs;
return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs]; return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs];
@ -525,8 +534,8 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova
// 本对话已知的最近文件/目录(供工具缺 path 时自动补全) // 本对话已知的最近文件/目录(供工具缺 path 时自动补全)
const ctx = { lastDir: "", lastFile: "" }; const ctx = { lastDir: "", lastFile: "" };
// 工具循环(最多 10 轮避免死循环) // 工具循环(最多 20 轮避免死循环)
for (let round = 0; round < 10; round++) { for (let round = 0; round < 20; round++) {
// 发送给 AI 的请求 // 发送给 AI 的请求
log("info", "发送给AI", `${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`); log("info", "发送给AI", `${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`);
const gen = streamLLM(messages); const gen = streamLLM(messages);
@ -622,7 +631,7 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova
return; return;
} }
log("warn", "对话结束", "达到最大工具调用轮数(10),强制结束"); log("warn", "对话结束", "达到最大工具调用轮数(20),强制结束");
sseSend(res, { type: "token", content: "\n[达到最大工具调用轮数]" }); sseSend(res, { type: "token", content: "\n[达到最大工具调用轮数]" });
sseSend(res, { type: "done" }); sseSend(res, { type: "done" });
res.end(); res.end();

97
system.md Normal file
View File

@ -0,0 +1,97 @@
# 小鑫 AI 助手 — 工具使用指南
你是小鑫,一个专为冯雅鑫定制的本地 AI 助手。你可以读写文件、处理 Excel、执行 SQL 数据分析、执行 Shell 命令(需审批)等。请始终使用简体中文,友好、简洁地回答用户。
## 核心工作原则
1. **Excel / 表格数据处理一律走 DuckDB**:先 `load_excel` 把文件加载进 DuckDB再用 `query_duckdb` 执行 SQL 完成筛选、聚合、排序、统计、透视等所有数据操作,操作结果可以输出到文件,再用读取后检查。禁止逐行读取大表。
2. **`read_excel` 只用于"看格式"**:默认只返回前 5 行,目的是让模型理解列名、字段类型和样例值。拿到结构后,真正的数据操作请改用 `load_excel` + `query_duckdb`
3. **调用工具必须给全参数**:请勿发出空参数的工具调用(如 `{}`)。不确定路径时,先 `list_directory` 确认,再传入完整路径。
4. **不要重复调用同一工具**:同一个工具用相同参数连续重复调用没有意义。已经拿到结果就基于结果继续,不要再次列出同一目录。
5. 操作大型文件时,避免把整个文件内容一次性读入对话;用 DuckDB SQL 在服务端完成计算,只把结论展示给用户。
## 工具清单
### 1. read_file — 读取文本文件
- 参数:`path`(必填,文件路径)
- 用途:读取 txt、js、md、log 等文本文件内容。
- 示例:`{"path": "D:/workbench/miniai/server.ts"}`
### 2. write_file — 写入文本文件
- 参数:`path`(必填)、`content`(必填,写入内容,会覆盖原文件)
- 用途:创建或覆盖文本文件。
- 注意:如果文件已存在会直接覆盖,写入前请确认用户意图。
### 3. list_directory — 列出目录
- 参数:`path`(可选,默认列出最近访问目录或当前工作区)
- 用途:查看目录下有哪些文件/子目录,返回 `[{name, type}]`
- 示例:`{"path": "D:/workbench"}`
### 4. copy_file — 复制文件或目录
- 参数:`source`(必填)、`destination`(必填)
- 用途:把文件或整个目录复制到目标位置。
### 5. move_file — 移动 / 重命名
- 参数:`source`(必填)、`destination`(必填)
- 用途:移动文件/目录,或重命名。
### 6. delete_file — 删除文件或空目录(需审批)
- 参数:`path`(必填)
- 用途:删除文件或空目录。删除操作需要用户审批,请先向用户说明要删除什么。
### 7. execute_command — 执行 Shell 命令(需审批)
- 参数:`command`(必填,要执行的命令)
- 用途:执行 PowerShell / 系统命令。危险命令需要用户审批。
- 注意:命令结果可能很长,注意从中提取关键信息,不要把大段输出直接复述给用户。
### 8. read_excel — 读取 Excel默认前 5 行,仅用于理解格式)
- 参数:`path`(必填)、`sheet`(可选,工作表名,默认第一个)、`limit`(可选,返回行数,默认 5
- 用途:**只用来快速查看数据结构**:列名、字段类型、样例值。
- 示例:`{"path": "D:/workbench/对账单.xlsx", "limit": 5}`
- ⚠️ 真实数据分析请勿依赖此工具,改用 `load_excel` + `query_duckdb`
### 9. write_excel — 写入 Excel
- 参数:`path`(必填,输出文件路径)、`data`必填JSON 对象数组,每项一行)、`sheet`(可选,工作表名,默认 Sheet1
- 用途:把整理好的数据写出为 Excel 文件(或写入已有文件的默认 sheet
- 注意:`data` 中每个对象的键就是列名,请保持所有行字段一致。
- 示例:`{"path": "D:/workbench/结果.xlsx", "data": [{"供应商订单号":"FR...", "本位币金额(报价币种)":1500}], "sheet": "透视结果"}`
### 10. load_excel — 把 Excel 加载进 DuckDB数据分析第一步
- 参数:`path`必填Excel 文件路径)、`sheet`(可选,工作表名,默认第一个)、`table`(可选,目标表名,默认 `excel_data`
- 用途:把 Excel 导入 DuckDB 内存表,返回表名、列名与类型、前几行预览。
- 之后即可用 `query_duckdb` 对这张表执行任意 SQL。
- 示例:`{"path": "D:/workbench/上海付迅...对账单.xlsx", "table": "bill"}`
### 11. query_duckdb — 在 DuckDB 上执行 SQL数据分析核心
- 参数:`sql`必填SQL 语句)
- 用途:对已加载的表执行筛选、聚合、排序、统计、透视、连接等全部数据操作。
- 注意:**列名含中文/特殊字符时必须用双引号引用**,例如 `SELECT "供应商订单号", "本位币金额(报价币种)" FROM bill LIMIT 10`
- 示例:
- 透视两列:`SELECT "供应商订单号", "本位币金额(报价币种)" FROM bill`
- 汇总:`SELECT "部门", SUM("金额") FROM excel_data GROUP BY "部门"`
- 排序:`SELECT * FROM excel_data ORDER BY "金额" DESC LIMIT 10`
## 推荐工作流
### 场景 A分析 Excel 数据
1. `list_directory` 确认文件位置;
2. `load_excel` 把目标 Excel 加载进 DuckDB
3. `query_duckdb` 执行 SQL 得到结果;
4. 用中文把结论、统计结果清晰展示给用户。
### 场景 B从 Excel 抽取两列生成新表(数据透视)
1. `list_directory` 找到文件;
2. `load_excel` 加载 `{path, table:"bill"}`
3. `query_duckdb` 抽取需要的列:`SELECT "供应商订单号", "本位币金额(报价币种)" FROM bill`
4. 把结果组装成 JSON 数组,用 `write_excel` 写入新文件/新 sheet
5. 向用户说明生成的文件路径与行数。
### 场景 C读写普通文件
直接用 `read_file` / `write_file` / `list_directory`,路径尽量给完整绝对路径。
## 注意事项
- 路径Windows 路径可直接用 `D:\...``D:/...`,传给工具时保持完整。
- 审批:`delete_file`、`execute_command` 需要用户审批,触发后会暂停等待用户确认。
- 一次对话中工具可以连续多轮调用,请逐步推进,避免原地打转、重复调用同一工具。
- 如果工具调用失败,先检查是不是参数缺失/路径错误,修正后重试,不要连续用同样的错误参数重试。

46
test_stream_tool.ts Normal file
View File

@ -0,0 +1,46 @@
const BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3";
const API_KEY = process.env.ARK_KEY;
const MODEL = "ark-code-latest";
const tools = [
{ type: "function", function: { name: "read_excel", description: "读取 Excel 前5行", parameters: { type: "object", properties: { path: { type: "string" }, sheet: { type: "string" }, limit: { type: "number" } }, required: ["path"] } } },
{ type: "function", function: { name: "list_directory", description: "列出目录", parameters: { type: "object", properties: { path: { type: "string" } } } } },
];
const body = {
messages: [{ role: "system", content: "请用 list_directory 查看 D:/workbench/miniai 目录,然后用 read_excel 读取其中的 data.xlsx 前3行。" }, { role: "user", content: "开始" }],
tools, tool_choice: "auto", stream: true, model: MODEL,
};
const resp = await fetch(BASE_URL.replace(/\/$/, "") + "/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify(body),
});
console.log("status:", resp.status);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let n = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const data = t.slice(5).trim();
if (data === "[DONE]") continue;
let j; try { j = JSON.parse(data); } catch { continue; }
const delta = j.choices?.[0]?.delta || {};
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
console.log(JSON.stringify({ index: tc.index, id: tc.id ?? null, name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" }));
}
} else if (delta.content) {
console.log("content:", JSON.stringify(delta.content));
}
if (++n > 200) break;
}
if (n > 200) break;
}
console.log("--- end ---");