手写Agent循环而非依赖框架,让开发者看清每一步推理与工具调用的细节。以~100行TypeScript实现支持票据处理,展示了ReAct模式的核心逻辑。
这是系列文章的第四篇,主题是从零开始构建一个不带任何框架的工单代理机器人。前一篇是第三篇(评估集)。仓库:github.com/akash-pal/agent-from-scratch
这是每个人都想用框架来跳过的部分。以下是不这样做的理由,至少第一次不要用框架:如果你不能用简单的英语解释你的代理循环在做什么,那么任何框架都解决不了这个问题——它只会让循环更难看清。
以下是 src/agent.ts,精简到实际的循环逻辑:
export async function runAgent(
ticket: Ticket,
customer: Customer | null,
approvalFn: ApprovalFn,
maxSteps = 8,
): Promise<AgentResult> {
const state = initState(ticket, customer);
// 安全检查在任何模型调用之前进行——见第五篇
const escalatePattern = matchAutoEscalate(`${ticket.subject} ${ticket.body}`);
if (escalatePattern) {
return { outcome: "escalated", finalText: `ESCALATED: auto-escalated — "${escalatePattern}"`, state };
}
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const contents: Content[] = [{ role: "user", parts: [{ text: ticketToUserMessage(ticket) }] }];
for (let step = 0; step < maxSteps; step++) {
const response = await withRetry(() =>
ai.models.generateContent({
model: MODEL,
contents,
config: { systemInstruction: buildSystemPrompt(COMPANY), tools: [{ functionDeclarations }] },
}),
);
const calls = response.functionCalls ?? [];
if (calls.length === 0) {
// 没有工具调用——模型生成了最终答案。完成。
const text = (response.text ?? "").trim();
return { ...enforceOutcomeIntegrity(parseOutcome(text), state), state };
}
// 否则:执行请求的工具,喂回结果,继续循环。
contents.push({ role: "model", parts: response.candidates?.[0]?.content?.parts ?? [] });
const responseParts = [];
for (const call of calls) {
const result = await executeToolWithGuardrails(call, state, approvalFn); // Part 5
responseParts.push({ functionResponse: { name: call.name, response: result } });
}
contents.push({ role: "user", parts: responseParts });
}
return { outcome: "escalated", finalText: `ESCALATED: max_steps_exceeded (${maxSteps} steps)`, state };
}
(实际文件中工具执行、审批门控和 trace 日志直接内联在循环中,而非单独函数——这里为便于理解才拆开展示。完整文件见 GitHub。)
这就是全部逻辑:调用模型,检查是否需要工具,需要则运行并继续循环,不需要则返回最终答案。没有状态机,没有图,没有单独的编排器抽象——就是一个 for 循环加一个 if。
系统提示词是循环的另一半,它被当作规范来对待,包含必需的部分,而不是自由形式的文本。来自 src/systemPrompt.ts:
You are the support agent for {company}. You are not a human agent — say so if asked.
GOAL: resolve or correctly escalate every ticket in as few steps as possible.
TOOLS: order_lookup (read order state) · refund_eligibility (check BEFORE
proposing a refund) · issue_refund (gated) · kb_search (use before any factual
answer) · send_email (gated, delivers the resolution).
POLICY: never call issue_refund without a prior refund_eligibility=true result.
Never fabricate an answer kb_search did not return. Never state REFUND_PROPOSED
unless issue_refund actually succeeded in this run.
UNCERTAINTY: if you are not confident, say so explicitly and escalate — do not guess.
DONE: emit a final response only once resolved, a refund is proposed, or the
ticket is escalated. Always start with RESOLVED:, REFUND_PROPOSED:, or
ESCALATED: so the outcome can be parsed.
六个必需要素:身份(它是谁、服务于谁)、一句话目标、工具列表及各自使用时机、明确策略(它不能做什么)、如何表达不确定性、如何表达完成。跳过其中任何一个,你都会在评估失败中看到它——经验来看,缺少明确的"如何表达完成"部分的提示词,是无法解析最终响应的最大来源。
注意那条关于 REFUND_PROPOSED 的 POLICY——"除非 issue_refund 真正在本轮运行中成功,否则不要声称 REFUND_PROPOSED。"这句话存在是因为,没有它的话模型确实就会这么做:描述一个已提议的退款但从未调用该工具。完整的故事——以及为什么修复需要的不止这一行提示词——是第五篇的内容。
"Memory"听起来应该意味着向量数据库。这里它只是一个对象,通过 reducer 更新——src/memory.ts:
export interface AgentState {
goal: string;
working: { ticket: Ticket }; // 仅本次运行
conversation: { trajectory: TrajectoryStep[] }; // 滚动的
artifacts: Record<string, unknown>; // 精确的、结构化的
long_term: Pick<Customer, "preferred_channel" | ...> | null; // 稳定的,本仓库为 mock
policy: typeof policy;
}
四个层级,按持久性和复杂性递增排序:working(仅本次运行——当前工单,临时性的)、conversation(到目前为止的轨迹)、artifacts(需要精确回忆的精确结构化事实,比如退款确认 ID——这类东西永远不要用模糊语义搜索)、long_term(跨会话的稳定事实——这里是只读 mock 数据,因为本仓库没有真正的持久化层)。
值得内化的决策规则:优先使用最便宜的层级。上下文会话 memory 对大多数单会话代理来说已经足够。外部结构化存储用于必须在运行之间持久化的事实。向量/语义 memory 是最昂贵、维护成本最高的选项,应该有充分的理由才使用,而不是默认使用——关键的是,对于有财务或访问控制影响的内容,永远不要用,因为"大致正确"的检索是错误的保证。退款确认 ID 作为精确字符串存入 artifacts,而不是被嵌入后在以后通过语义搜索找回。
第五篇:护栏活在代码里,而不是提示词里 → 涵盖了这个循环在每次工具调用时依赖的护栏层:policy-as-code 审批门控,以及添加它的真正原因——一个真实的 bug。