跳到主要内容

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. 这份模板怎么扩成真实项目

准备把它从单文件模板扩成真实项目时,可以按这个顺序往下扩:

  1. 先接上真实 searchSources
  2. 再接上真实 readSource
  3. 再把 decideNextAction 改成模型决策
  4. 再补结构化 trace 和最小 eval
  5. 最后再考虑冲突自动消解、来源加权和更复杂的规划

可以直接记成:

先让系统真的会研究,再考虑让它研究得更聪明。

5. 第一版不要急着加什么

这份模板第一版不建议马上补这些:

  • 多 Agent 协作
  • 长期记忆
  • 自动生成很复杂的 research plan
  • 太多层的抽象基类
  • 自动把所有冲突都“解决掉”

因为 Research Agent 第一版最重要的不是:

  • 功能很多
  • 表现很炫

而是:

  • 研究维度清楚
  • evidence 边界清楚
  • gap / conflict 记录清楚
  • 最终输出真的能解释“为什么这么判断”

6. 一句话用法

如果只想尽快开始:

  1. 先复制整份模板
  2. 替换研究目标和 dimensions
  3. 接上真实 searchSources
  4. 接上真实 readSource
  5. 先跑通 “搜来源 -> 读来源 -> 存 evidence -> 补 gaps -> 收束结论”

做到这一步,就已经从“会检索”走到了真正有研究闭环的 Research Agent 第一版。