跳到主要内容

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_docs
  • read_doc

它们的作用不是让你真的拿去生产使用,而是给你一个最小的工具接口:

type ToolDefinition = {
name: string;
description: string;
run: (args: ToolArgs) => Promise<ToolResult>;
};

你后面只需要保持这个接口不变,再替换具体实现。

例如可以改成:

  • search_web
  • fetch_page
  • query_database
  • read_local_file
  • call_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 只保留了最小运行必需项:

  • goal
  • currentStep
  • maxSteps
  • notes
  • evidence
  • toolCalls
  • pendingQuestions
  • finalAnswer
  • done

这套结构已经够你完成第一版闭环。

更重要的是,它表达了一个很实用的思路:

state 不是为了“看起来完整”,而是为了支持下一步决策。

如果某个字段不会影响:

  • 继续还是结束
  • 调哪个工具
  • 最后怎么回答

那它大概率不该进第一版 state。

3.5 替换 logger

模板里的日志只有一个最小函数:

function logStep(message: string, extra?: unknown)

第一版日志建议只记 4 类事:

  • Agent 开始
  • 每步决策
  • 工具结果摘要
  • Agent 结束

这已经足够你排查很多问题。

不要第一天就急着上:

  • 完整 tracing
  • 分布式观测
  • 复杂 span 嵌套
  • 多维 metrics 面板

你完全可以先用 console.log 跑通,确认循环结构稳定后,再把这里替换成:

  • pino
  • winston
  • 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 分工
  • 长期记忆系统
  • 复杂缓存层
  • 通用工具协议抽象
  • 超大的状态对象
  • 一开始就做成框架

更实用的顺序通常是:

  1. 先让最小循环跑通
  2. 再确认工具边界是否清楚
  3. 再确认状态是否真的支持决策
  4. 再接真实模型
  5. 再做观测、测试和重构

如果只是先做第一版,可以先守住一句话:

先把一个能结束、能输出、能解释每一步的最小 Agent 写出来。

6. 一句话用法

如果只想马上开始:

  1. 复制上面的完整模板代码
  2. 先替换 goal
  3. 再替换两个 tool
  4. 然后只改 decideNextAction()
  5. 先跑通,再考虑扩展

这样你就不是在“设计一个 Agent 框架”,而是在:

完成一个最小、可验证、可继续演进的 Agent 第一版。