ll1r1k-1337/llm-tool-capability
GitHub: ll1r1k-1337/llm-tool-capability
为不支持原生 function calling 的 LLM 提供兼容 OpenAI 格式的工具调用能力,支持代理模式、client 包装和自动 agent 循环。
Stars: 0 | Forks: 0
# llm-tool-capability
为原生不支持 function
calling 的 LLM 提供**兼容 OpenAI 的开箱即用工具调用。** 它会将你的工具注入到
prompt 中,将模型返回的文本解析回 OpenAI 格式的 `tool_calls`,并且可以为你运行
整个 agentic loop —— 支持流式传输。
适用于任何兼容 OpenAI 的 endpoint:**Ollama、vLLM、LM Studio、
llama.cpp**、text-generation-webui 等。
```
npm install llm-tool-capability
```
`openai` 是一个可选的 peer dependency —— 如果你想包装真实的
OpenAI client,请安装它(你也可以传递任何兼容 OpenAI 的 client 对象)。
## 为什么
许多开源模型非常擅长遵循指令,但**没有提供 `tools`
参数** —— 服务器会拒绝它或直接忽略它。这个 package
依然能让 `tools` / `tool_choice` 生效,通过:
1. 将你的工具 schema 和调用契约渲染到 system prompt 中。
2. 要求模型以带有标记的 ` ```tool_call ` JSON 块的形式发出调用。
3. 将这些块解析回**完全**符合 OpenAI 的
`message.tool_calls` 格式 (`{ id, type: "function", function: { name, arguments } }`,
其中 `arguments` 是一个 JSON **字符串**)。
你可以通过**三种方式**使用它:作为零代码的 **proxy**(运行一个服务器,将你的
OpenAI client 指向它),作为**直接替换的 client**(在代码中包装你的 client),或者作为
**agentic runner**(它为你运行工具循环)。
## Proxy 模式(无需修改代码)
在你的不支持工具的模型前运行一个本地兼容 OpenAI 的 proxy。任何 OpenAI
client 只需要将其 `baseURL` 指向该 proxy —— 无需其他更改。
```
npx llm-tool-proxy --upstream http://localhost:11434/v1 --port 8787
# llm-tool-proxy 监听于 http://127.0.0.1:8787/v1
# → upstream: http://localhost:11434/v1
```
现在将任何 OpenAI client 指向它,并像往常一样传递 `tools`:
```
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:8787/v1", apiKey: "unused" });
const res = await client.chat.completions.create({
model: "qwen2.5:7b",
messages: [{ role: "user", content: "What's the weather in Paris?" }],
tools: [/* … */], // ← works, even though the model has no native tools
});
// res.choices[0].message.tool_calls → populated, OpenAI shape (streaming too)
```
proxy 会将所有内容转发到 upstream,注入工具契约,
解析回工具调用,并通过 SSE 流式传输 —— 传输格式与 OpenAI 完全相同。
**CLI 标志:** `--upstream `(必填)、`--upstream-key`、`--port`、
`--host`、`--api-key`(要求 client 提供 bearer token)、`--base-path`、
`--tag`、`--no-examples`、`--system-injection merge|prepend`、`--cors`(启用
通配符 CORS —— 默认关闭)、`--max-body-size `(默认 10 MiB)、
`--log-file `(追加 client 请求、转换后的 upstream 请求和响应的 JSON-lines 调试日志 —— 内容详细;body 会被记录,但
header/token 绝不会被记录)、`--max-log-size `(限制日志文件大小;默认
100 MiB)。主要标志具有对应的环境变量(`UPSTREAM_BASE_URL`、`PORT`、
`PROXY_API_KEY`、`PROXY_LOG_FILE`,…)。
使用以下代码将 proxy 嵌入到你自己的服务器中,而不是使用 CLI:
```
import { createProxyServer } from "llm-tool-capability/proxy";
createProxyServer({
upstreamBaseURL: "http://localhost:11434/v1",
apiKey: process.env.PROXY_API_KEY, // optional client auth
}).listen(8787);
```
**Endpoints:** `POST /v1/chat/completions`(支持工具)和 `GET /health`。
基础路径下的所有其他路由 —— `/v1/completions`、`/v1/embeddings`、
`/v1/models` 等 —— 都会**透明地原样传递**给 upstream
(不进行工具注入;这些 endpoints 没有工具),因此该 proxy 是一个
完整的直接替换方案,而不仅仅是一个 chat endpoint。
## Layer A —— 直接替换的 client
`wrapToolSupport(client)` 返回一个 client,其
`chat.completions.create` 可以直接替换 OpenAI 的方法。像往常一样传递 `tools`;就像往常一样获取
返回的 `tool_calls`。当你不传递 `tools` 时,它是完全
透明的。
```
import OpenAI from "openai";
import { wrapToolSupport } from "llm-tool-capability";
const openai = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
const client = wrapToolSupport(openai);
const res = await client.chat.completions.create({
model: "llama3.1",
messages: [{ role: "user", content: "What's the weather in Paris?" }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
],
});
const toolCalls = res.choices[0].message.tool_calls;
// [{ id: "call_…", type: "function",
// function: { name: "get_weather", arguments: '{"city":"Paris"}' } }]
```
由你自己驱动循环:执行调用,追加一条 `role: "tool"` 消息
(带有 `tool_call_id`),然后再次调用。该 wrapper 会自动将这些
原生工具角色重写回 prompt 契约中 —— 因此一个正常的 OpenAI 工具调用
循环就可以直接工作。
### 流式传输(Layer A)
```
const stream = await client.chat.completions.create({
model: "llama3.1",
messages,
tools,
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) process.stdout.write(delta.content); // prose, token by token
if (delta?.tool_calls) handleToolCallDelta(delta.tool_calls); // OpenAI chunk deltas
}
```
正文内容会逐个 token 进行流式传输。每个工具调用在其
块闭合时**原子地**发出(完整的 `arguments` 包含在一个 delta 中)—— 这可以避免在流式传输过程中出现
部分/无效的 JSON。就像使用
OpenAI 一样,通过 `index` 进行累加。
## Layer B —— agentic runner
`createToolRunner` 为你执行循环:请求 → 解析 → 运行 handler →
反馈结果 → 重复,直到模型在不调用工具的情况下做出回答。
```
import OpenAI from "openai";
import { createToolRunner, defineTool } from "llm-tool-capability";
const openai = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
const runner = createToolRunner(openai, {
tools: [
defineTool({
name: "get_weather",
description: "Get the current weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
handler: async ({ city }) => {
const r = await fetch(`https://api.example.com/weather?city=${city}`);
return r.json();
},
}),
],
maxIterations: 8,
});
const result = await runner.run({
model: "llama3.1",
messages: [{ role: "user", content: "Is it raining in Paris?" }],
});
console.log(result.content); // final answer
console.log(result.toolExecutions); // every tool call + result, in order
console.log(result.messages); // full transcript
```
将**原始(raw)** client 传递给 `createToolRunner` —— 它会在内部对其进行包装。
### 流式传输事件(Layer B)
```
for await (const ev of runner.runStream({ model: "llama3.1", messages })) {
switch (ev.type) {
case "text": process.stdout.write(ev.delta); break;
case "tool_call": console.log("→ calling", ev.toolCall.function.name); break;
case "tool_result": console.log("← result", ev.execution.content); break;
case "final": console.log("\ndone:", ev.content); break;
}
}
```
### 错误反馈
未知工具、JSON schema 无效的参数、格式错误的 JSON 以及抛出异常的 handler 都
**不是**致命的:错误会作为工具结果反馈给模型,以便
它可以在下一轮中自我纠正。每一次错误都会记录在
`result.toolExecutions[i]` 中,并标记为 `isError: true`。
## 工作原理
| 关注点 | 行为 |
| --- | --- |
| 调用格式 | ` ```tool_call ` 块包含 `{"name", "arguments"}`(arguments 是一个 JSON 对象)。标记可配置。 |
| 多次调用 | 多个连续的块,或者一个块内的数组。 |
| 格式错误的 JSON | 轻度修复(尾随逗号、注释);回退到原始字符串。 |
| 宽松解析 | 如果找不到带标记的块,则接受看起来像调用的 ` ```json `/无标记块(可通过 `lenientFences` 切换)。 |
| 历史记录 | 原生的 `assistant.tool_calls` 和 `role: "tool"` 消息会自动扁平化回契约中。 |
| 校验 | 通过 `ajv` 根据每个工具的 JSON Schema 对参数进行校验(可通过 `validate` 切换)。 |
| `tool_choice` | `auto`(默认)、`required`、`{ function: { name } }` 和 `none` 通过 prompt 指令来执行。 |
| 循环安全 | `maxIterations` 上限(默认为 10);返回 `finishReason: "max_iterations"` 或通过 `throwOnMaxIterations` 抛出异常。 |
## 选项
`wrapToolSupport(client, options)` / `createToolRunner(client, options)` 共享以下选项:
- `toolCallTag` / `toolResultTag` —— 标签标记(默认 `tool_call` / `tool_result`)。
- `includeExamples` —— 包含一个 few-shot 示例(默认 `true`;能力较弱的模型会从中受益)。
- `template` —— 完全自定义指令块。
- `systemInjection` —— `"merge"`(追加到现有的 system 消息中,默认)或 `"prepend"`。
- `lenientFences` —— 接受 ` ```json `/无标记的相似内容(默认 `true`)。
- `generateId` —— 自定义工具调用 id 生成器。
仅 Runner 选项:`tools`、`maxIterations`、`validate`、`throwOnMaxIterations`、
`onToolCall`、`onToolResult`。
## 构建块
内部组件已导出,可用于自定义 pipeline:`buildToolPrompt`、
`parseToolCalls`、`ToolCallStreamParser`、`ToolValidator`、`flattenMessages`、
`extractFencedBlocks`、`tryParseJson`。
## 限制
- **工具调用参数以原子方式流式传输**,而不是逐个 token(正文内容确实
是逐个 token 流式传输的)。这是为了稳健性而做出的刻意权衡。
- 质量取决于模型遵循指令的能力。较小的模型在使用
`includeExamples: true` 以及简短、清晰的工具列表时效果更好。
- 仅支持 `function` 工具(与 OpenAI 的 function 工具匹配);
较新的 `custom` 工具不在范围内。
- **流式传输仅处理第一个 choice。** `n > 1` 适用于非流式
请求(每个 choice 独立解析),但不适用于流式传输 —— 这
在实际情况中没问题,因为 OpenAI 禁止同时使用 `n > 1` 和 `tools`。
## 许可证
MIT
标签:AI代理, AI风险缓解, API代理, DLL 劫持, GNU通用公共许可证, MITM代理, Node.js, Petitpotam, SOC Prime, 人工智能, 大语言模型, 开发工具, 暗色界面, 用户模式Hook绕过, 自动化攻击