Minimal Agent TypeScript 模板
这篇文档只做一件事:
把最小 Agent 骨架整理成一份可以直接复制的 TypeScript 模板。
如果已经理解了最小 Agent 的基本概念,但每次一落到代码就会卡住:
goal到底放哪里tool应该怎么定义decision长什么样state要存哪些东西- 日志该记到什么程度
这一页就是给你拿来直接改的。
1. 适合什么时候用这个模板
这 个模板适合下面这种阶段:
- 想先跑通一个最小 Agent,而不是一开始就上复杂框架
- 已经知道需要
goal -> decide -> tool -> observe -> finish这条主线 - 想先把核心循环写清楚,再逐步接入真实模型、真实工具和真实日志系统
- 希望第一版代码边界清楚,方便后面重构
它比较适合这些第一版场景:
- 文档调研助手
- 页面信息采集助手
- 轻量工作流执行助手
- 内部知识问答前的检索代理
如果任务一开始就需要:
- 多 Agent 协作
- 并行工具调度
- 长期记忆
- 复杂规划与反思
那这个模板就不是终点,而是更合适的起点。
2. 完整 TypeScript 模板代码
下面这份代码刻意保持为:
单文件、最小依赖、完整骨架。
可以先整体复制,再只替换后面指出的几个位置。
// minimal-agent-template.ts
type ToolArgs = Record<string, unknown>;
type ToolResult = {
ok: boolean;
content: string;
};
type ToolDefinition = {
name: string;
description: string;
run: (args: ToolArgs) => Promise<ToolResult>;
};
type ToolCallRecord = {
step: number;
toolName: string;
args: ToolArgs;
resultSummary: string;
};
type AgentState = {
goal: string;
currentStep: number;
maxSteps: number;
notes: string[];
evidence: string[];
toolCalls: ToolCallRecord[];
pendingQuestions: string[];
finalAnswer: string | null;
done: boolean;
};
type AgentDecision =
| {
type: "tool";
toolName: string;
args: ToolArgs;
reason: string;
}
| {
type: "finish";
reason: string;
answer: string;
};
function createInitialState(goal: string): AgentState {
return {
goal,
currentStep: 0,
maxSteps: 5,
notes: [],
evidence: [],
toolCalls: [],
pendingQuestions: [],
finalAnswer: null,
done: false,
};
}
const tools: ToolDefinition[] = [
{
name: "search_docs",
description: "Search for documents or articles related to the goal",
async run(args) {
const query = String(args.query ?? "");
return {
ok: true,
content: `Search results for: ${query}\n- Result A\n- Result B`,
};
},
},
{
name: "read_doc",
description: "Read one selected document in more detail",
async run(args) {
const source = String(args.source ?? "");
return {
ok: true,
content: `Document content from: ${source}\nSummary: This source looks useful.`,
};
},
},
];
function getToolByName(name: string): ToolDefinition | undefined {
return tools.find((tool) => tool.name === name);
}
function logStep(message: string, extra?: unknown) {
const now = new Date().toISOString();
if (extra === undefined) {
console.log(`[${now}] ${message}`);
return;
}
console.log(`[${now}] ${message}`, extra);
}
async function decideNextAction(state: AgentState): Promise<AgentDecision> {
if (state.currentStep === 0) {
return {
type: "tool",
toolName: "search_docs",
args: { query: state.goal },
reason: "Need broad material before making a conclusion.",
};
}
if (state.evidence.length < 2 && state.toolCalls.length > 0) {
return {
type: "tool",
toolName: "read_doc",
args: { source: "top-search-result" },
reason: "Need one concrete source to support the answer.",
};
}
return {
type: "finish",
reason: "Enough information collected for a first answer.",
answer: buildFinalAnswer(state),
};
}
function updateStateAfterTool(
state: AgentState,
decision: Extract<AgentDecision, { type: "tool" }>,
result: ToolResult,
): AgentState {
const nextState: AgentState = {
...state,
currentStep: state.currentStep + 1,
notes: [...state.notes],
evidence: [...state.evidence],
toolCalls: [...state.toolCalls],
pendingQuestions: [...state.pendingQuestions],
};
nextState.toolCalls.push({
step: nextState.currentStep,
toolName: decision.toolName,
args: decision.args,
resultSummary: result.content.slice(0, 160),
});
nextState.notes.push(`Step ${nextState.currentStep}: ${decision.reason}`);
if (result.ok) {
nextState.evidence.push(result.content);
} else {
nextState.pendingQuestions.push(`Tool failed: ${decision.toolName}`);
}
if (nextState.currentStep >= nextState.maxSteps) {
nextState.done = true;
nextState.finalAnswer = buildFinalAnswer(nextState);
}
return nextState;
}
function buildFinalAnswer(state: AgentState): string {
const evidencePreview = state.evidence.slice(0, 2).join("\n\n");
return [
`Goal: ${state.goal}`,
"Conclusion: Based on the collected evidence, this is the first-pass answer.",
evidencePreview ? `Evidence:\n${evidencePreview}` : "Evidence: Not enough evidence collected.",
`Steps used: ${state.toolCalls.length}`,
].join("\n\n");
}
async function runAgent(goal: string) {
let state = createInitialState(goal);
logStep("Agent started", { goal: state.goal, maxSteps: state.maxSteps });
while (!state.done) {
const decision = await decideNextAction(state);
logStep("Decision made", decision);
if (decision.type === "finish") {
state = {
...state,
done: true,
finalAnswer: decision.answer,
};
break;
}
const tool = getToolByName(decision.toolName);
if (!tool) {
state = {
...state,
done: true,
finalAnswer: `Tool not found: ${decision.toolName}`,
};
break;
}
const result = await tool.run(decision.args);
logStep("Tool finished", {
toolName: decision.toolName,
ok: result.ok,
preview: result.content.slice(0, 120),
});
state = updateStateAfterTool(state, decision, result);
}
logStep("Agent finished", {
totalSteps: state.currentStep,
toolCalls: state.toolCalls.length,
});
return {
finalAnswer: state.finalAnswer ?? buildFinalAnswer(state),
state,
};
}
async function main() {
const goal = "判断某个 AI 框架是否适合做内部知识助手";
const result = await runAgent(goal);
console.log("\n=== Final Answer ===\n");
console.log(result.finalAnswer);
}
main().catch((error) => {
console.error("Agent crashed:", error);
process.exit(1);
});
3. 需要替换 的几个位置
这份模板最重要的使用方式不是“全部重写”,而是:
先保留循环,再替换局部。
下面这几个位置是你第一步应该改的。
3.1 替换 goal
默认模板里的 goal 是:
const goal = "判断某个 AI 框架是否适合做内部知识助手";
你要做的是把它改成真实任务目标。
比如:
判断这个 SDK 是否适合接入现有项目收集某个产品的公开定价信息从指定页面提取 onboarding 关键信息
这里有一个简单原则:
goal 要写成一句清楚的任务,而不是一句泛泛的方向。
不要一上来写:
帮我研 究一下 AI做一个万能助手
第一版 goal 越具体,后面的决策越稳定。
3.2 替换 tool
模板里先放了两个最小工具:
search_docsread_doc
它们的作用不是让你真的拿去生产使用,而是给你一个最小的工具接口:
type ToolDefinition = {
name: string;
description: string;
run: (args: ToolArgs) => Promise<ToolResult>;
};
你后面只需要保持这个接口不变,再替换具体实现。
例如可以改成:
search_webfetch_pagequery_databaseread_local_filecall_internal_api
第一版建议每个工具都保持这几个特点:
- 只做一件事
- 参数名字直接可读
- 返回值先统 一成
ToolResult - 不要一开始塞进太多可选参数
如果工具一上来就变成:
do_everything(query, filters, mode, source, strategy, fallback, retries...)
那通常说明它还不适合放进第一版模板里。
3.3 替换 decision
模板里的决策逻辑非常朴素:
- 第一步先搜
- 证据不够就读
- 证据够了就结束
对应代码在 decideNextAction():
async function decideNextAction(state: AgentState): Promise<AgentDecision>
这里是整份模板后续可以慢慢升级的地方,第一版不用着急。
你现在只要先学会把决策写成这两类:
type: "tool"type: "finish"
这会强迫Agent 每一步都回答一个很清楚的问题:
下一步到底是继续调用工具,还是已经可以结束?
你后面如果要接大模型,也优先让模型产出这个最小决策结构,而不是一开始就让它输出复杂规划树。
3.4 替换 state
模板里的 state 只保留了最小运行必需项:
goalcurrentStepmaxStepsnotesevidencetoolCallspendingQuestionsfinalAnswerdone
这套结构已经够你完成第一版闭环。
更重要的是,它表达了一个很实用的思路:
state 不是为了“看起来完整”,而是为了支持下一步决策。
如果某个字段不会影响:
- 继续还是结束
- 调哪个工具
- 最后怎么回答
那它大概率不该进第一版 state。
3.5 替换 logger
模板里的日志只有一个最小函数:
function logStep(message: string, extra?: unknown)
第一版日志建议只记 4 类事:
- Agent 开始
- 每步决策
- 工具结果摘要
- Agent 结束
这已经足够你排查很多问题。
不要第一天就急着上:
- 完整 tracing
- 分布式观测
- 复杂 span 嵌套
- 多维 metrics 面板
你完全可以先用 console.log 跑通,确认循环结构稳定后,再把这里替换成:
pinowinston- OpenTelemetry
- 你们团队自己的日志封装
4. 如何扩成真实项目
当这份模板跑通后,你通常会按下面这个顺序把它扩成真实项目。
4.1 先把假工具换成真工具
第一步不是换框架,而是把这两个占位工具改成真实 IO:
- 真搜索接口
- 真网页读取
- 真数据库查询
- 真文件读取
只要工具接口不变,循环部分通常就不用大改。
4.2 再把决策函数接到模型
下一步再把:
decideNextAction(state)
从硬编码逻辑改成:
- 调用 LLM
- 让模型基于当前 state 决定下一步
- 再把模型输出解析成
AgentDecision
这里最关键的是:
让模型输出结构化 decision,而不是直接输出整段最终答案。
因为Agent 真正的控制面,不在“会不会说”,而在“下一步做什么”。
4.3 给工具结果加更稳定的结构
如果项目变复杂,你很快会发现 content: string 不够用了。
这时可以把 ToolResult 扩成:
type ToolResult = {
ok: boolean;
content: string;
raw?: unknown;
metadata?: Record<string, unknown>;
};
这样可以同时保留:
- 给模型看的摘要
- 给系统内部处理的原始结果
- 调试和回放用的元信息
4.4 把单文件拆成几个责任清楚的模块
当模板进入真实项目后,建议再拆文件,而不是一开始就拆。
一个很自然的拆法是:
agent/types.ts:放类型定义agent/tools.ts:放工具定义agent/decision.ts:放决策逻辑agent/state.ts:放状态更新agent/run.ts:放主循环
先单文件跑通,再拆文件,通常比一开始就设计很多层更稳。
4.5 增加测试和回放能力
当 Agent 开始接真实模型和真实工具后,优先尽早补的是:
- 决策函数测试
- 工具适配层测试
- 固定输入的回放测试
- 失败案例回归测试
因为 Agent 项目后面最贵的成本,往往不是“写出来”,而是:
改了之后你不知道有没有悄悄变差。
5. 第一版不要急着加什么
这个部分非常重要。
很多最小 Agent 不是死在“不会写”,而是死在:
第一版加太多。
下面这些东西不是永远不要,而是第一版先别急着上:
- 自动规划器
- 自我反思循环
- 多模型路由
- 多 Agent 分工
- 长期记忆系统
- 复杂缓存层
- 通用工具协议抽象
- 超大的状态对象
- 一开始就做成框架
更实用的顺序通常是:
- 先让最小循环跑通
- 再确认工具边界是否清楚
- 再确认状态是否真的支持决策
- 再接真实模型
- 再做观测、测试和重构
如果只是先做第一版,可以先守住一句话:
先把一个能结束、能输出、能解释每一步的最小 Agent 写出来。
6. 一句话用法
如果只想马上开始:
- 复制上面的完整模板代码
- 先替换
goal - 再替换两个
tool - 然后只改
decideNextAction() - 先跑通,再考虑扩展
这样你就不是在“设计一个 Agent 框架”,而是在:
完成一个最小、可验证、可继续演进的 Agent 第一版。