1. 从零构建大模型 Agent 应用:TypeScript 技术栈下的完整链路
大模型 Agent 应用不是「套个聊天框」那么简单。我在实际项目里踩过最深的坑,是把 Agent 当成一个纯 LLM 调用来写,结果工具调用乱飞、上下文爆炸、RAG 检索回来的内容跟当前任务八竿子打不着。后来才想明白一件事:Agent 应用 = Application + Agent + MCP,Application 是地基,Agent 是大脑,MCP 是手脚。地基不稳,大脑再聪明也白搭。
这篇内容聚焦 TypeScript 技术栈,从零跑通一个可用的 Agent 应用。你会拿到三样东西:一份可直接复制的settings.json与config.toml配置骨架、TaoToken 统一 Key 的接入步骤、以及 Agent 调用 MCP 工具的完整验证动作。中间会穿插 RAG 检索增强和 Context Engineering 上下文管理的实操细节。适合已经会写 TypeScript、但还没把 Agent 链路串起来的开发者。读完你至少能跑通一个「用户提问 → 任务拆分 → 工具调用 → 结果回传」的最小闭环。
2. 前置准备:TaoToken 统一 Key 与项目骨架
2.1 为什么需要统一 Key 层
Agent 应用通常要调用多个模型:拆分任务用推理型、执行任务用代码型、RAG 重排可能又是另一个。如果每个模型都单独配一套 Key 和 endpoint,配置文件会迅速失控。TaoToken 的做法是提供一个统一的 API 入口,你只需要维护一个 Key,就能在多个模型之间切换。
官网地址:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= API 地址:https://taotoken.net/api
注意 API 地址不带 UTM 参数,直接用于代码里的baseURL。
2.2 获取 Key 与项目初始化
先去控制台创建一个 API Key:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console拿到 Key 之后,初始化 TypeScript 项目:
mkdir agent-mcp-demo && cd agent-mcp-demo npm init -y npm install typescript tsx @types/node --save-dev npm install openai zod npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext这里用openaiSDK 作为客户端,因为 TaoToken 的 API 兼容 OpenAI 协议格式,换baseURL即可。zod用来做工具参数的 schema 校验,后面 MCP 工具注册会用到。
2.3 环境变量配置
在项目根目录创建.env:
TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_BASE_URL=https://taotoken.net/api然后在src/config.ts里读取:
import OpenAI from "openai"; export const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); export const MODELS = { splitter: "claude-sonnet-4-20250514", executor: "claude-sonnet-4-20250514", embedding: "text-embedding-3-small", } as const;把模型名集中管理,后面切换模型只改这一处。我试过在代码里散落写模型名,改一次要全局搜索,非常痛苦。
3. 可复制配置:settings.json 与 config.toml 骨架
3.1 settings.json:Agent 运行时配置
这个文件定义 Agent 的行为边界、工具白名单和上下文窗口策略。放在项目根目录:
{ "agent": { "name": "task-scheduler-agent", "maxTurns": 12, "contextWindow": 128000, "reserveTokens": 8000, "hitl": { "enabled": true, "confirmOnToolCall": true, "confirmOnTaskSplit": true } }, "tools": { "registry": "./tools", "extensions": [".mcp.ts"], "timeoutMs": 30000, "maxConcurrent": 3 }, "rag": { "enabled": true, "topK": 5, "scoreThreshold": 0.72, "chunkSize": 512, "chunkOverlap": 64 }, "models": { "splitter": "claude-sonnet-4-20250514", "executor": "claude-sonnet-4-20250514", "embedding": "text-embedding-3-small" } }几个关键参数说明。maxTurns限制 Agent 最多循环多少轮,防止死循环烧 token。reserveTokens是给系统提示词和工具定义预留的空间,Context Engineering 的核心就是别让对话历史把窗口撑爆。hitl.confirmOnToolCall打开后,每次工具调用前会暂停等用户确认,这是保证可靠性的关键设计。
3.2 config.toml:MCP 工具链配置
MCP 工具的注册信息用 TOML 管理,可读性比 JSON 好:
[mcp] version = "1.0" transport = "stdio" [[mcp.tools]] name = "read_task_list" description = "读取工作区中所有任务脚本的元信息" entry = "./tools/read_task_list.mcp.ts" requires_confirm = false [[mcp.tools]] name = "write_task" description = "在工作区创建新的 TypeScript 任务脚本" entry = "./tools/write_task.mcp.ts" requires_confirm = true [[mcp.tools]] name = "ripgrep_task" description = "在任务脚本内容中执行正则搜索" entry = "./tools/ripgrep_task.mcp.ts" requires_confirm = false [mcp.limits] max_output_bytes = 65536 allowed_paths = ["./workspace"]requires_confirm控制单个工具是否需要人工确认。读操作可以放开,写操作必须确认。allowed_paths做路径隔离,防止 Agent 写到工作区外面去。
3.3 工具定义文件示例
以read_task_list.mcp.ts为例,展示 MCP 工具的标准写法:
import { z } from "zod"; import fs from "node:fs/promises"; import path from "node:path"; export const schema = z.object({ fullPath: z.string().describe("任务脚本的绝对路径"), }); export const description = "读取指定路径下任务脚本的元信息"; export async function handler(args: z.infer<typeof schema>) { const content = await fs.readFile(args.fullPath, "utf-8"); const lines = content.split("\n").slice(0, 20); return { path: args.fullPath, preview: lines.join("\n"), size: content.length, }; }这个文件同时导出了schema、description和handler。加载器会扫描./tools目录下所有.mcp.ts文件,用ts-morph解析出这些导出,自动生成给 LLM 看的工具定义 XML。你不需要手写工具描述,改代码即改描述。
4. 验证请求:跑通 Agent 调用 MCP 工具
4.1 加载工具并生成上下文
先写一个工具加载器,把.mcp.ts文件转成 LLM 能理解的格式:
import { glob } from "node:fs/promises"; import path from "node:path"; export async function loadTools(toolDir: string) { const tools = []; for await (const file of glob(`${toolDir}/*.mcp.ts`)) { const mod = await import(path.resolve(file)); tools.push({ name: path.basename(file, ".mcp.ts"), description: mod.description, schema: mod.schema, handler: mod.handler, }); } return tools; }然后把工具定义拼成 XML 塞进 System Prompt。根据 Claude Code 的实践经验,LLM 对 XML 标签的理解比 JSON 更稳:
export function buildToolPrompt(tools: Awaited<ReturnType<typeof loadTools>>) { const toolXml = tools .map( (t) => `<tool> <name>${t.name}</name> <description>${t.description}</description> <parameters>${JSON.stringify(t.schema.shape)}</parameters> </tool>` ) .join("\n"); return `<tools> ${toolXml} </tools> <tool_calling> 你有可用的工具来解决编码任务。遵循以下规则: 1. 始终完全遵循指定的工具调用模式,确保提供所有必需参数。 2. 在调用每个工具之前,先向用户解释调用原因。 3. 一次回答中只能调用一次工具,除非调用之间没有依赖关系。 4. 将调用工具的 <invoke> 标签放在整个回答的最末尾。 </tool_calling>`; }4.2 发起一次完整请求
写一个最小可运行脚本src/run.ts:
import "dotenv/config"; import { client, MODELS } from "./config.js"; import { loadTools, buildToolPrompt } from "./tools-loader.js"; async function main() { const tools = await loadTools("./tools"); const toolPrompt = buildToolPrompt(tools); const response = await client.chat.completions.create({ model: MODELS.executor, messages: [ { role: "system", content: `你是一个任务调度平台的开发助手。 ${toolPrompt}`, }, { role: "user", content: "帮我创建一个每分钟采集 CPU 使用率的任务脚本", }, ], temperature: 0.2, }); console.log(response.choices[0].message.content); } main();运行:
npx tsx src/run.ts4.3 预期结果与工具调用解析
正常输出应该包含思考过程和<invoke>标签,类似:
我需要先查看工作区现有的任务脚本,避免命名冲突。 <invoke> <name>read_task_list</name> <reason>读取工作区现有任务,确认命名规范</reason> <params> <param> <name>fullPath</name> <value>./workspace</value> </param> </params> </invoke>解析这段 XML 的代码:
export function parseInvoke(text: string) { const match = text.match(/<invoke>([\s\S]*?)<\/invoke>/); if (!match) return null; const name = match[1].match(/<name>(.*?)<\/name>/)?.[1]; const reason = match[1].match(/<reason>(.*?)<\/reason>/)?.[1]; const params: Record<string, string> = {}; const paramRegex = /<param>\s*<name>(.*?)<\/name>\s*<value>(.*?)<\/value>\s*<\/param>/g; let m; while ((m = paramRegex.exec(match[1])) !== null) { params[m[1]] = m[2]; } return { name, reason, params }; }拿到{ name, reason, params }之后,去tools数组里找到对应的handler,校验参数后执行,把结果包成<tool-call-result>塞回对话历史,继续下一轮。这就是 Agent 循环的核心。
4.4 RAG 检索增强的接入点
在工具执行结果回传之前,可以加一层 RAG。比如ripgrep_task返回了 20 条匹配结果,不要全塞给 LLM,先用 embedding 做一次重排:
export async function rerank(query: string, docs: string[], topK = 5) { const queryEmb = await client.embeddings.create({ model: MODELS.embedding, input: query, }); const scored = await Promise.all( docs.map(async (doc) => { const docEmb = await client.embeddings.create({ model: MODELS.embedding, input: doc, }); const score = cosine(queryEmb.data[0].embedding, docEmb.data[0].embedding); return { doc, score }; }) ); return scored .filter((s) => s.score > 0.72) .sort((a, b) => b.score - a.score) .slice(0, topK) .map((s) => s.doc); }scoreThreshold和topK就是settings.json里配的那两个值。阈值设太低会引入噪声,设太高会漏掉相关内容,0.72 是我在几个项目里试出来的平衡点,你可以根据实际语料调整。
5. 本篇常见错排查
5.1 工具加载报错Cannot find module
症状:loadTools执行时抛ERR_MODULE_NOT_FOUND。
原因通常是tsconfig.json的moduleResolution没设成NodeNext,导致.mcp.ts文件里的import路径解析失败。检查你的tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true } }另外,动态import()的路径必须是绝对路径或带file://前缀。用path.resolve(file)包一层最稳妥。
5.2 LLM 不输出<invoke>标签
症状:模型回复了文字,但没有工具调用标签。
三个排查方向。第一,System Prompt 里<tools>标签的内容是否为空,如果工具加载失败但没报错,LLM 看不到任何工具自然不会调用。第二,temperature是否设得太高,超过 0.5 之后模型倾向于自由发挥,建议工具调用场景设在 0.1 到 0.3。第三,用户提问是否太模糊,模型判断不需要工具就能回答。把提问改具体,比如「读取 ./workspace 下的任务列表」而不是「帮我看看任务」。
5.3 上下文窗口溢出
症状:请求报context_length_exceeded。
这是 Context Engineering 没做好的典型表现。对话历史每轮都在增长,工具返回的日志可能几千 token。解决方案是在每轮循环开始前做一次裁剪:
export function trimContext(messages: any[], maxTokens: number) { let total = 0; const kept = []; for (let i = messages.length - 1; i >= 0; i--) { const len = JSON.stringify(messages[i]).length / 4; if (total + len > maxTokens) break; total += len; kept.unshift(messages[i]); } return kept; }保留最近的对话,把早期的工具调用结果压缩成摘要。settings.json里的reserveTokens就是给这个操作留的余量。
5.4 MCP 工具执行超时
症状:handler执行超过timeoutMs被中断。
如果是ripgrep_task这类搜索工具,大概率是搜索范围太大。在工具实现里加路径限制,只搜allowed_paths配置的目录。如果是write_task写文件慢,检查是不是写到了网络挂载盘。超时时间可以在settings.json的tools.timeoutMs里调,但不建议超过 60 秒,否则用户体验很差。
6. 下一步:把链路跑稳,再谈优化
到这里你已经有了一个能跑的最小闭环:TaoToken 统一 Key 接入、MCP 工具注册、RAG 检索增强、Context Engineering 裁剪、HITL 人工确认。接下来要做的不是加更多功能,而是把这条链路跑稳。
几个我踩过坑之后总结的实用建议。第一,工具描述一定要写清楚「什么时候用」和「什么时候不用」,LLM 对否定约束的遵循度比你想的高。第二,每次工具调用结果回传时,在<tool-call-result>里带上执行耗时和状态码,方便排查问题。第三,HITL 的确认按钮不要做成「全部确认」,要支持单条任务确认,否则用户会烦。
如果你在接入过程中遇到 API 报错或工具注册失败,可以先查接入文档:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc想先验证模型对话是否正常,用模型对话页面发一条测试消息:
https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model如果你打算长期做编码类 Agent,或者要跑多轮工具调用的复杂任务,Coding Plan 的额度模型比按量计费更划算:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding-planKey 管理和用量查看在控制台:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=consoleAPI Key 创建入口:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api-keys最后说一个细节。Claude Code 的 Anthropic 兼容接入方式在 TaoToken 上也能用,如果你习惯用 Claude Code 做开发,可以直接把 endpoint 指过来:
https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=claudecode把上面这套配置跑通之后,你会发现 Agent 应用的难点从来不是模型不够聪明,而是 Application 层能不能给 Agent 提供稳定、可预期、有边界的工具和上下文。MCP 工具链的质量,直接决定了 Agent 的上限。