Research Agent TypeScript 模板
这篇文档只做一件事:
把 Research Agent 的最小工程骨架整理成一份可以直接复制的 TypeScript 模板。
如果已经看过:
但真正自己动手时还是会卡在这些地方:
- 研究维度到底该怎么放进
state evidence / gaps / conflicts应该怎么存- 搜索和阅读工具怎么分层
- 什么时候该继续调研,什么时候该收束
- 第一版模板该保留哪些最小边界
这一页就是给你直接复制、再按需替换的。
1. 适合什么时候用这个模板
这个模板适合下面这类系统:
- AI 框架选型调研助手
- 技术方案对比助手
- 模型接入可行性调研助手
- 企业知识分析助手
它比较适合下面这种状态:
- 已经不满足于单轮问答
- 需要系统围绕若干维度持续补资料
- 希望最终输出的是“有依据的判断”,而不是资料拼接
- 想先做一个轻量版本,不想一开始就接太重的框架
如果只是做:
- FAQ
- 简单知识问答
- 不需要比较与综合的检索场景
那普通 RAG 或最小 Tool-Using Agent 往往已经够用了。
2. 完整 TypeScript 模板代码
下面这份模板刻意保持为:
单文件、最小依赖、完整主线。
可以先整段复制,再替换后面标出来的几个位置。
// research-agent-template.ts
type ResearchDimensionId =
| "capability_fit"
| "integration_cost"
| "operational_risk"
| "known_limitations";
type ResearchDimension = {
id: ResearchDimensionId;
question: string;
done: boolean;
confidence: number;
};
type SearchHit = {
id: string;
title: string;
source: string;
snippet: string;
};
type Evidence = {
id: string;
dimensionId: ResearchDimensionId;
claim: string;
sourceTitle: string;
sourceUrl: string;
snippet: string;
supportLevel: "strong" | "medium" | "weak";
};
type InformationGap = {
dimensionId: ResearchDimensionId;
question: string;
reason: "not_enough_evidence" | "conflict_detected" | "too_generic";
};
type ConflictRecord = {
dimensionId: ResearchDimensionId;
claimA: string;
claimB: string;
sourceA: string;
sourceB: string;
resolutionStatus: "open" | "resolved" | "needs_human_review";
};
type ToolResult<T> = {
ok: boolean;
data?: T;
error?: string;
};
type ResearchState = {
goal: string;
dimensions: ResearchDimension[];
evidence: Evidence[];
gaps: InformationGap[];
conflicts: ConflictRecord[];
notes: string[];
currentStep: number;
maxSteps: number;
done: boolean;
finalAnswer: string | null;
};
type ResearchDecision =
| {
type: "search";
dimensionId: ResearchDimensionId;
query: string;
reason: string;
}
| {
type: "read";
dimensionId: ResearchDimensionId;
hitId: string;
reason: string;
}
| {
type: "finish";
reason: string;
answer: string;
};
function createInitialState(goal: string): ResearchState {
return {
goal,
dimensions: [
{
id: "capability_fit",
question: "Does the solution cover the required capability?",
done: false,
confidence: 0,
},
{
id: "integration_cost",
question: "What is the integration and engineering cost?",
done: false,
confidence: 0,
},
{
id: "operational_risk",
question: "What are the main operational risks?",
done: false,
confidence: 0,
},
{
id: "known_limitations",
question: "What are the known limitations or unsuitable cases?",
done: false,
confidence: 0,
},
],
evidence: [],
gaps: [],
conflicts: [],
notes: [],
currentStep: 0,
maxSteps: 8,
done: false,
finalAnswer: null,
};
}
async function searchSources(
query: string,
): Promise<ToolResult<SearchHit[]>> {
return {
ok: true,
data: [
{
id: "source-1",
title: `Official doc for ${query}`,
source: "https://example.com/doc",
snippet: "Relevant snippet from official docs",
},
{
id: "source-2",
title: `Engineering blog for ${query}`,
source: "https://example.com/blog",
snippet: "Relevant snippet from engineering blog",
},
],
};
}
async function readSource(
hitId: string,
): Promise<ToolResult<{ content: string }>> {
return {
ok: true,
data: {
content: `Detailed content for ${hitId}. It contains evidence that supports or limits one research dimension.`,
},
};
}
function logStep(message: string, extra?: unknown) {
const now = new Date().toISOString();
if (extra === undefined) {
console.log(`[${now}] ${message}`);
return;
}
console.log(`[${now}] ${message}`, extra);
}
function getNextOpenDimension(
state: ResearchState,
): ResearchDimension | undefined {
return state.dimensions.find((item) => !item.done);
}
function extractEvidence(
dimensionId: ResearchDimensionId,
hit: SearchHit,
content: string,
): Evidence {
return {
id: `${hit.id}-${dimensionId}`,
dimensionId,
claim: `Claim for ${dimensionId} derived from ${hit.title}`,
sourceTitle: hit.title,
sourceUrl: hit.source,
snippet: content.slice(0, 180),
supportLevel: "medium",
};
}
function markDimensionProgress(state: ResearchState, dimensionId: ResearchDimensionId) {
const evidenceCount = state.evidence.filter(
(item) => item.dimensionId === dimensionId,
).length;
state.dimensions = state.dimensions.map((dimension) => {
if (dimension.id !== dimensionId) {
return dimension;
}
return {
...dimension,
confidence: Math.min(1, evidenceCount / 2),
done: evidenceCount >= 2,
};
});
}
async function decideNextAction(
state: ResearchState,
lastHits: SearchHit[],
): Promise<ResearchDecision> {
const openDimension = getNextOpenDimension(state);
if (!openDimension) {
return {
type: "finish",
reason: "All dimensions have enough evidence",
answer: buildFinalAnswer(state),
};
}
const dimensionEvidenceCount = state.evidence.filter(
(item) => item.dimensionId === openDimension.id,
).length;
if (dimensionEvidenceCount === 0 || lastHits.length === 0) {
return {
type: "search",
dimensionId: openDimension.id,
query: `${state.goal} ${openDimension.question}`,
reason: "Need candidate sources for this dimension",
};
}
return {
type: "read",
dimensionId: openDimension.id,
hitId: lastHits[0].id,
reason: "Need deeper evidence for this dimension",
};
}
function buildFinalAnswer(state: ResearchState): string {
const sections = state.dimensions.map((dimension) => {
const evidence = state.evidence.filter(
(item) => item.dimensionId === dimension.id,
);
const evidenceText = evidence
.map((item) => `- ${item.claim} (${item.sourceTitle})`)
.join("\n");
return [
`Dimension: ${dimension.id}`,
`Confidence: ${dimension.confidence.toFixed(2)}`,
evidenceText || "- No evidence collected",
].join("\n");
});
return [
`Goal: ${state.goal}`,
"Research Summary:",
...sections,
state.gaps.length > 0
? `Open gaps: ${state.gaps.map((item) => item.question).join("; ")}`
: "Open gaps: none",
].join("\n\n");
}
async function runResearchAgent(goal: string) {
let state = createInitialState(goal);
let lastHits: SearchHit[] = [];
while (!state.done && state.currentStep < state.maxSteps) {
const decision = await decideNextAction(state, lastHits);
logStep("research_decision", decision);
if (decision.type === "finish") {
state = {
...state,
finalAnswer: decision.answer,
done: true,
};
break;
}
if (decision.type === "search") {
const result = await searchSources(decision.query);
if (!result.ok || !result.data) {
state.gaps.push({
dimensionId: decision.dimensionId,
question: decision.query,
reason: "not_enough_evidence",
});
lastHits = [];
} else {
lastHits = result.data;
state.notes.push(
`Found ${result.data.length} candidate sources for ${decision.dimensionId}`,
);
}
}
if (decision.type === "read") {
const hit = lastHits.find((item) => item.id === decision.hitId);
const result = await readSource(decision.hitId);
if (!hit || !result.ok || !result.data) {
state.gaps.push({
dimensionId: decision.dimensionId,
question: `Could not read source ${decision.hitId}`,
reason: "not_enough_evidence",
});
} else {
const evidence = extractEvidence(
decision.dimensionId,
hit,
result.data.content,
);
state.evidence.push(evidence);
markDimensionProgress(state, decision.dimensionId);
state.notes.push(
`Added evidence for ${decision.dimensionId} from ${hit.title}`,
);
}
}
state.currentStep += 1;
}
if (!state.finalAnswer) {
state.finalAnswer = buildFinalAnswer(state);
}
return state;
}
async function main() {
const state = await runResearchAgent(
"Evaluate whether this framework is suitable for an internal knowledge assistant",
);
console.log("\n=== FINAL ANSWER ===");
console.log(state.finalAnswer);
}
main().catch((error) => {
console.error("Research agent failed", error);
process.exit(1);
});
3. 这份模板里优先替换的几个位置
3.1 dimensions
这是 Research Agent 第一版最重要的区别之一。
你要先明确:
- 研究要覆盖哪些面
- 每一面分别在问什么
第一版最好控制在 3 到 4 个维度。
3.2 searchSources
这里后面可以接成真实的:
- web search
- 文档搜索
- 内部知识库检索
- 向量检索或 hybrid search
第一版不要急着混太多检索方式。
3.3 readSource
这个位置代表:
Research Agent 不只是找来源,还会进一步读来源。
它后面可以替换成:
- 读取完整文档
- 读取章节内容
- 读取网页正文
- 读取内部知识条目的详情
3.4 evidence / gaps / conflicts
这是 Research Agent 和普通检索系统很不一样的地方。
你要尽量保留:
- 哪条 evidence 支持哪个维度
- 哪些问题还没搞清楚
- 哪些来源彼此冲突
不要只保留一堆原始文本。
3.5 decideNextAction
这一层后面的常见升级方向是:
- 把硬编码决策改成模型输出结构化决策
- 让模型判断先搜哪个维度
- 让模型决定继续搜、继续读还是开始总结
4. 这份模板怎么扩成真实项目
准备把它从单文件模板扩成真实项目时, 可以按这个顺序往下扩:
- 先接上真实
searchSources - 再接上真实
readSource - 再把
decideNextAction改成模型决策 - 再补结构化 trace 和最小 eval
- 最后再考虑冲突自动消解、来源加权和更复杂的规划
可以直接记成:
先让系统真的会研究,再考虑让它研究得更聪明。
5. 第一版不要急着加什么
这份模板第一版不建议马上补这些:
- 多 Agent 协作
- 长期记忆
- 自动生成很复杂的 research plan
- 太多层的抽象基类
- 自动把所有冲突都“解决掉”
因为 Research Agent 第一版最重要的不是:
- 功能很多
- 表现很炫
而是:
- 研究维度清楚
- evidence 边界清楚
- gap / conflict 记录清楚
- 最终输出真的能解释“为什么这么判断”
6. 一句话用法
如果只想尽快开始:
- 先复制整份模板
- 替换研究目标和
dimensions - 接上真实
searchSources - 接上真实
readSource - 先跑通 “搜来 源 -> 读来源 -> 存 evidence -> 补 gaps -> 收束结论”
做到这一步,就已经从“会检索”走到了真正有研究闭环的 Research Agent 第一版。