启动并运行 ADK-TS 代理非常简单:选择或创建工具、编写系统提示、构建模式,然后就大功告成了。然而,将其部署给实际用户使用才是最困难的部分。在开发过程中触发过一次的外部调用可能会因为有人不停地点击按钮而连续触发五次。上游应用程序接口可能会对你的请求进行速率限制。或者第三方服务在演示过程中返回 502。
这就是 ADK-TS 中 插件和 生命周期回调的作用所在。它们可让您在 TypeScript AI 代理周围构建缓存、重试、度量和错误处理层,而无需触及代理本身。
本文将介绍您在几乎每个 ADK-TS 代理上都会采用的三种模式:
- 编写可挂钩工具调用生命周期的自定义插件。
- 组成多个插件,以便它们能够整齐地堆叠。
- 在插件无法触及的服务器操作边界处理模型级故障。
我们将在一个名为 The Draft Desk 的小型 Next.js 应用程序中完成上述三项工作,该应用程序是一个由人工智能驱动的工具,可将博文转化为平台定制的社交草稿。应用程序本身很小,但重要的是其模式。本文假定您已经构建了一个基本的 ADK-TS 代理,如果还没有,《如何使用 ADK-TS 在 TypeScript 中构建您的第一个 AI 代理》是一个很好的开始。如果您想在深入了解之前更广泛地了解该框架所提供的功能,介绍 TypeScript 的代理开发工具包将为您提供帮助。
What we're building:草稿台
草稿台接收博客 URL、音调和一组平台,获取文章,并为每个选择编写一个平台定制的草稿。对于 X 和 Threads,它还支持 线程模式(2-10 篇文章的连锁线程),而 LinkedIn 则始终是单篇文章。
在引擎盖下,它是一个小型堆栈:
- 一个 单一代理,带有一个内置工具(
WebFetchTool)和一个 Zod 输出模式。 - 两个 插件:一个我们将编写的自定义缓存插件,以及一个我们将堆叠在其上的内置重试插件。
- 一个调用代理的薄 Next.js服务器动作层。
没有协调器、没有子代理、没有提示链。只有一个代理,执行一项工作。
(播放视频播放视频暂停视频矩形0:00 0:00/1:03。1×。静音.
设置启动项目
克隆 repo 并切换到启动分支:
git clone <https://github.com/IQAIcom/adk-ts-samples.git>;
cd adk-ts-samples/apps/social-media-drafting-agent
git checkout starter
pnpm 安装
启动分支包含了除代理层之外的所有内容:
- 完整的 UI 在
src/components和src/app/page. TypeScript 域类型在src/types.ts.一个环境模式在env.ts.用户界面。Stub Server Actions 在 src/app/actions.ts 中,当前会抛出 "Not implemented yet"(尚未实现)。
复制 env 示例,从 Google AI Studio 中获取一个键,然后启动开发服务器:
cp .env.example .env
# 将您的 GOOGLE_API_KEY 粘贴到 .env 中
pnpm dev
http://localhost:3000用户界面已加载,但单击 草稿会出现错误。这是意料之中的,因为代理尚未构建。
Building the baseline agent
在我们扩展任何内容之前,我们需要一个代理来扩展。创建代理文件:
mkdir -p src/agents/draft-generator
然后在其中创建一个 agent.ts 文件,并开始导入和输出模式:
import { AgentBuilder, WebFetchTool } from "@iqai/adk";
import z from "zod";
import { env } from ".../../../env";
export const postDraftsSchema = z.object({
文章:z.object({
url:z.string()、
title: z.string()、
}),
草稿:z.array(
z.object({
platform: z.enum(["linkedin", "x", "threads"])、
内容:z.string()、
段落:z.array(z.string()).optional()、
hashtags: z.array(z.string())、
}),
),
});
export type PostDraftsOutput = z.infer<typeof postDraftsSchema>;
模式中的两个小细节值得一提:
platform: z.enum(["linkedin", "x", "threads"]) 将代理锁定为我们的三个平台。如果模型尝试返回"bluesky",那么在结果到达我们的代码之前,模式验证就会失败。segments: z.array(z.string()).withTools(new WebFetchTool())从 ADK-TS 中为代理提供内置的 Web 抓取工具。您无需进行 HTML 解析。withOutputSchema(postDraftsSchema)根据 Zod 模式验证每个响应。withInstruction(...)是系统提示。主分支中的真实版本详细说明了每个平台的字符数限制、URL 处理规则和线程与发布行为,具体的数字每次都能击败模糊的指导。
beforeToolCallback在调用工具前触发。如果它返回一个值,框架将短路该工具 - 代理将接收该值,就像该工具产生了该值一样,而实际工具永远不会运行。afterToolCallback在工具成功返回后触发。它可以在代理看到结果之前观察或转换结果。- 缓存,就像我们将要在这里做的一样,但也适用于昂贵的查找,如地理编码、用户配置文件获取或任何具有可预测密钥的内容。
- Rate limiting(速率限制)- 当您正在使用第三方 API 时,可自动关闭。
- Auth injection(权限注入)- 为每个需要 OAuth 令牌的工具调用添加一个新的 OAuth 令牌,而工具或代理并不知情。
- Redaction - 在访问日志或返回客户端之前,从结果中剥离 API 密钥、PII 或机密。
cache- 以 URL 为关键字的Map,存储结果和过期时间戳。ttlMs- 缓存条目保持有效的时间。默认为一小时。keyFor(args)- 从工具参数中提取 URL。不使用 URL 的工具(您将来可能添加的工具)会在此处返回null并被忽略。
- Lifecycle 回调(
beforeToolCallback/afterToolCallback)可让您拦截、替换或观察工具调用,而无需接触代理或工具。 - Plugin composition - 多个插件按照您传递给
.withPlugins(...)的顺序堆叠。 - Action-boundary 错误处理可捕获插件无法看到的故障:模型过载、速率限制和模式违规。将其规范化后,日志将保持丰富,而用户将看到干净的消息。
- 缓存订单查询的客户支持代理,因此相同的问题不会每次都出现在您的数据库中。
- 记录每次工具调用(文件读取、测试运行、差异)以进行审计或计费的代码审查代理。
- 在 CI 中模拟 Slack 和 PagerDuty 工具调用的运营代理。
- 自动重试瞬时数据库超时或 Webhook 5xxs 的数据管道代理。
- 任何您希望在不重写代理本身的情况下获得可观察性、弹性或功能标记行为的代理。
- 添加平台。在
src/types.ts中扩展Platform,添加PLATFORM_SPECS条目,并在系统提示中更新平台规则。用户界面将自动适应。 - 更改输入。将
WebFetchTool换成WebSearchTool后,输入将变成一个主题而不是 URL - "关于最新浏览器版本的社交帖子草稿"。 - 添加审查员。使用 ADK-TS 的
SequentialAgent链接第二个代理,该代理在返回之前根据品牌声音对草稿进行审核。 - Schedule it. 使用 BullMQ 或 Inngest 将草稿排到目标发布时间 - Draft Desk 成为一个编辑工作流。
Metrics - 计算每个用户、每个租户、每个会话的工具调用次数,以便计费或观察。下面是我们代理的缓存版本。创建 src/agents/draft-generator/web-fetch-cache-plugin.ts 并从类 shell 开始:
import type { BaseTool, ToolContext } from "@iqai/adk";
import { BasePlugin } from "@iqai/adk";
导出类 WebFetchCachePlugin extends BasePlugin {
private cache = new Map<string, { result: unknown; expiresAt: number }>();
private readonly ttlMs: number;
构造函数(ttlMs: number = 60 * 60 * 1000){
super("web-fetch-cache");
this.ttlMs = ttlMs;
}
private keyFor(args: Record<string, unknown>): string | null {
const url = args.url;
return typeof url === "string" ?
}
// 回调到此处
}
WebFetchCachePlugin类扩展了BasePlugin,ADK-TS 基类是每个插件的继承。在该类中:现在是两个回调。首先,beforeToolCallback:
async beforeToolCallback(params:{
工具BaseTool;
toolArgs:record<string, unknown>;
toolContext:toolContext;
}):Promise<Record<string, unknown> | undefined> {
if (params.tool.name !== "web_fetch") return undefined;
const key = this.keyFor(params.toolArgs);
如果 (!key) 返回未定义;
const hit = this.cache.get(key);
if (hit && Date.now() < hit.expiresAt) {
return hit.result as Record<string, unknown>;
}
return undefined;
}
这将在任何工具调用之前运行。首先,它会过滤 web_fetch - 插件会看到每个工具调用,而此回调只关注获取。然后,它会在缓存中查找 URL。如果有新的命中,它就会返回缓存结果,然后框架就会短路:代理获得的结果就好像 web_fetch 刚刚运行过一样。如果未命中(或没有缓存条目),则返回 undefined,这将告知框架正常运行该工具。然后是存储成功结果的 afterToolCallback:
async afterToolCallback(params:{
工具BaseTool;
toolArgs:record<string, unknown>;
toolContext:toolContext: 工具上下文;
result:Record<string, unknown>;
}):Promise<Record<string, unknown> | undefined> {
if (params.tool.name !== "web_fetch") return undefined;
const key = this.keyFor(params.toolArgs);
如果 (!key) 返回未定义;
if ((params.result as { success?: boolean }).success !== false) {
this.cache.set(key, {
result: params.result、
expiresAt:Date.now() + this.ttlMs、
});
}
return undefined;
}
此项在工具调用成功返回后运行。与 web_fetch 工具的过滤器相同。如果结果不是明确的失败,我们会将其存储在缓存中,并设置一个新的过期时间。返回undefined将原封不动地传递结果--我们只是观察,而不是转换。这两个方法都位于第一个代码段中的 WebFetchCachePlugin 类中。这里有三件事适用于您编写的任何 TypeScript 代理插件:回调合约。从 beforeToolCallback 返回 undefined 意味着 "让工具正常运行"。返回一个值意味着 "跳过工具,给代理这个值"。Filter by tool name. if (params.tool.name !== "web_fetch") return undefined;防护非常重要,因为插件是全局的--代理的每次工具调用都会触发每个插件的回调。如果没有该过滤器,以 URL 为关键字的缓存将尝试拦截甚至不使用 URL 的工具。插件状态存在于实例中。缓存是插件类上的Map。由于插件连接到一个单例运行程序(这也是我们之前将其设置为单例的原因),因此在 Node 进程的整个生命周期中,该缓存将在服务器动作调用中继续存在。如果您希望看到 beforeToolCallback 和 afterToolCallback 用于不同的目的(对工具执行硬速率限制而非缓存),我们将在 Build a Research Assistant AI Agent with TypeScript and ADK-TS (Part 2\) 中介绍该方法。现在将插件连接到代理中。打开 agent.ts 并在顶部添加导入以及插件的模块级实例:
import { WebFetchCachePlugin } from "./web-fetch-cache-plugin";
const webFetchCachePlugin = new WebFetchCachePlugin(60 * 60 * 1000);
然后使用 .withPlugins(...) 将其附加到构建程序链:
export const getDraftGenerator = async () => {
const { runner } = await AgentBuilder.create("draft_generator")
.withDescription(/* ... */)
.withInstruction(/* ... */)
.withModel(env.LLM_MODEL)
.withTools(new WebFetchTool())
.withPlugins(webFetchCachePlugin)
.withOutputSchema(postDraftsSchema)
.build();
返回运行程序;
};
现在,重新启动开发服务器。第一稿仍然需要 4-8 秒,但随后的重写只需大约一秒即可完成。LLM 仍在写入新的文本--这就是重写的意义所在--但在会话的剩余时间里,获取功能已经消失。服务器日志也证实了这一点:在第一代中只有一次 web_fetch 调用,之后就没有了。主要启示 beforeToolCallback\+ afterToolCallback 可让您在不接触代理或工具的情况下拦截、替代或观察工具调用。缓存是其中一种用途;验证注入、速率限制、度量、节录和测试模拟都是同一模式的变体。
Pattern 2:为分层代理行为编写插件
互联网是不稳定的。第三方 API 会返回 502,连接会在请求中途中断,上游服务会毫无预警地限制您的速率。与其编写您自己的重试逻辑,不如在缓存顶部堆叠第二个插件。ADK-TS 提供的 ReflectAndRetryToolPlugin 正是用于此目的。将其添加到 agent.ts 中的导入:
导入 {
AgentBuilder、
WebFetchTool、
ReflectAndRetryToolPlugin、
} from "@iqai/adk";
在缓存插件旁边创建一个实例:
const webFetchCachePlugin = new WebFetchCachePlugin(60 * 60 * 1000);
const reflectRetryPlugin = new ReflectAndRetryToolPlugin({
name: "web_fetch_retry"、
maxRetries: 2、
throwExceptionIfRetryExceeded: true、
});
然后将这两个插件传递给构建程序链中的 .withPlugins(...):
.withPlugins(webFetchCachePlugin, reflectRetryPlugin)
ReflectAndRetryToolPlugin对失败的工具调用进行重试,最多可重试 maxRetries 次。反映 "部分意味着它将告诉模型在两次尝试之间出了什么问题,因此模型可以进行调整--尝试不同的 URL 形状、丢弃破损的标头--而不是重复相同的失败调用。没有它,耗尽的重试将无声地返回空结果。空结果将通过模式验证,其中包含垃圾信息,并且看起来像是成功生成。Plugin order matters. .withPlugins(webFetchCachePlugin, reflectRetryPlugin) 先运行缓存,然后再运行重试。缓存命中短路时,重试才会看到调用。在未命中时,两个插件都会按照通常的顺序运行。翻转顺序后,重试将在缓存检查之前运行--虽然仍然正确,但每次缓存命中都会浪费周期。这就是组合插件的好处:每个插件只做一件事,您可以按照任何合理的顺序堆叠它们,而不会出现试图同时处理缓存、重试、日志记录和度量的辅助函数纠缠不清的情况。您将它们传递给 .withPlugins(...) 的顺序就是它们运行的顺序。从小型、单一用途的插件(缓存、重试、度量、日志)中构建分层行为,而不是将关注点混合在一个地方。
Pattern 3:在服务器操作边界处理模型故障
让我们来看看真实会话中的这个错误:
未能根据模式解析和验证 LLM 输出。
原始输出:错误:{"error":{"code":503, "message": "This model is currently experiencing high demand.需求高峰通常是暂时的。请稍后再试。", "status": "UNAVAILABLE"}}
出现此错误的原因是模型的 API 响应为 503。重试插件仅有助于工具调用,因此无法捕获此错误。该故障上升到服务器动作,服务器动作将其作为错误抛出,而 Next.js 则将其转化为堆栈跟踪提供给用户。ReflectAndRetryToolPlugin在这里没有起到任何作用。该插件旨在重试工具调用,而非模型调用本身。当模型的 API 返回 503 时,插件没有机会进行干预,因为故障发生在任何工具调用的范围之外。当插件无法看到故障时,您将在上一层(服务器操作边界)进行处理。在 src/app/actions.ts 顶部附近添加重试辅助器:
type DraftRunner = Awaited<ReturnType<typeof getDraftGenerator>>;
const TRANSIENT_PATTERNS =
/\b503\b|UNAVAILABLE|overload|high demand|RESOURCE_EXHAUSTED|\b429\b|ECONNRESET|ETIMEDOUT|fetch failed/i;
async 函数 askWithRetry(
runner:DraftRunner、
prompt: string、
maxRetries = 2、
):Promise<unknown> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await runner.ask(prompt);
catch (err) {
lastError = err;
const msg = err instanceof Error ?
if (!TRANSIENT_PATTERNS.test(msg) || attempt === maxRetries) throw err;
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
}
throw lastError;
}
此辅助程序会重试整个 runner.ask(prompt) 调用,其中包括模型响应。它会查找错误消息中的瞬态模式(503、速率限制、超时、连接重置),并以指数后退方式重试 maxRetries 次。如果重试次数用尽或看到与瞬时模式不匹配的错误,它将抛出错误,以便下一层处理。让我们添加一个辅助器,将原始错误转化为用户友好的消息:
function toUserMessage(error: unknown): string {
console.error("[actions]", error);
const raw = error instanceof Error ? error.message : String(error);
if (/\b503\b|UNAVAILABLE|overload|high demand/i.test(raw)) {
return "The model is overloaded right now.几秒钟后再试。";
}
if (/RESOURCE_EXHAUSTED|\b429\b|rate.?limit|quota/i.test(raw)) {
return "Rate-limited by the AI provider.稍等片刻后重试。";
}
if (
/ENOTFOUND|ETIMEDOUT|ECONNREFUSED|ECONNRESET|fetch failed|getaddrinfo/i.test(raw)
){
return "无法访问文章 URL。请检查链接并重试。";
}
if (/\b404\b/i.test(raw)) {
return "Article not found at that URL.";
}
if (/paywall|login.?required/i.test(raw)) {
return "This article is behind a login or paywall - can't read it.";
}
if (/ZodError|invalid_type|Invalid input|parse.*schema/i.test(raw)) { 返回 "该模型返回了意外响应。
return "The model returned an unexpected response.请重试。";
}
return "生成草稿时出了问题。请重试。";
}
此函数在原始错误消息中查找已知模式,并返回一条简洁、用户友好的消息。它还会记录完整的错误以便调试。您可以根据在实践中看到的错误类型自定义模式和消息。将这两个助手应用到 previewPosts 中:将 runner.ask(prompt) 替换为 askWithRetry(runner, prompt) 并用 try/catch 包装主体。形状如下:
export async function previewPosts(params:{
url: string;
tone:Tone;
platforms:Platform[];
format:PostFormat;
threadLength: number;
}):Promise<PreviewResult> {
const { url, tone, platforms, format }= params;
const threadLength = clampThreadLength(params.threadLength);
if (platforms.length === 0) {
throw new Error("至少选择一个平台");
}
try {
const runner = await ensureDraftRunner();
const prompt = `...`; // 与之前的提示相同
const result = (await askWithRetry(runner, prompt)) as AgentOutput;
const selected = new Set(platforms);
const drafts:PlatformDraft[] = result.drafts
.filter((d) => selected.has(d.platform))
.map((d) =>;
.buildDraft(d.platform, d.content, d.hashtags, d.segments, format)、
);
return { article: result.article, drafts };
} catch (error) {
throw new Error(toUserMessage(error));
}
}
对 regenerateDraft 执行相同操作 - 将其正文包入 try/catch 中,并将 runner.ask 换为 askWithRetry 。如果需要,您可以对每个操作的错误处理进行微调--也许 regenerateDraft 有一组不同的瞬态模式,或者您需要不同的用户消息。主要启示:插件可处理工具级故障。模型级故障(过载、速率限制、模式违规)以及与之相关的面向用户的消息属于服务器操作边界。了解哪个层拥有哪种故障,您就不会再纠结为什么重试插件无法捕获 503。
Summary: three patterns for any production TypeScript agent
如果您从本文中获得了任何信息,那么这三点就是您所需要的:Draft Desk 就是一个例子。这些相同的模式几乎会出现在任何地方:
Where to take it-next
以下是如何使用相同模式进一步扩展 Draft Desk 的一些想法:
Resources
使用此模式构建了什么?请在ADK-TS社区中分享。
segments: z.array(z.string())可选()让单一模式涵盖两种形状 - 单一帖子(仅 content )和链式线程(content 加上 segments ) - 而无需联合类型。 const { runner } = await AgentBuilder.create("draft_generator") .withDescription( "获取博文并生成平台优化的社交媒体草稿。返回结构化的 JSON"、 ) .withInstruction( 您是社交媒体内容专家。使用 web_fetch 工具读取文章,然后为每个请求的平台生成一个草稿,并遵守每个平台的字符限制。只返回与输出模式匹配的有效 JSON、 ) .withModel(env.LLM_MODEL) .withTools(new WebFetchTool()) .withOutputSchema(postDraftsSchema) .build(); 返回运行程序; };生成器链中有三项工作:现在将代理连接到服务器操作。打开 src/app/actions.ts 并替换当前存根。从 "use server" 指令和导入开始:
"use server";
import { getDraftGenerator } from "@/agents/draft-generator/agent";
导入 {
类型 ArticlePreview、
platform_specs、
类型平台、
类型 PlatformDraft、
类型 PostFormat、
预览结果类型、
thread_length_max、
thread_length_min、
类型音、
} from "@/types";
顶部的"use server" 指令将此文件标记为服务器操作 - 每个导出函数都将成为用户界面可调用的内容,就像普通的异步函数一样,Next.js 将在两者之间处理 RPC。接下来是代理运行程序的单例:
// 单例 - 一次构建运行程序,在不同请求中重复使用。
让 draftRunner:Awaited<ReturnType<typeof getDraftGenerator>> | null = null;
async 函数 ensureDraftRunner() {
if (!draftRunner) draftRunner = await getDraftGenerator();
return draftRunner;
}
Server Actions 按请求运行,但模块状态会在 Node 进程的整个生命周期中保持不变。我们构建一次运行程序,将其缓存在 draftRunner 中,然后在每次后续调用时将其交还。这比它看起来更重要--我们即将添加的插件是有状态的,它们的状态会在运行程序实例中持续存在。几个用于请求验证的小助手:
const isThreadable = (platform: Platform): boolean =>;
platform === "x" || platform === "线程";
const clampThreadLength = (n: number): number =>;
Math.min(THREAD_LENGTH_MAX, Math.max(THREAD_LENGTH_MIN, Math.round(n)));
isThreadable标记哪些平台是可线程的(LinkedIn 不能)。clampThreadLength是对客户端请求的帖子数的输入验证--如果用户界面发送threadLength: 100或0,则会在我们将其传递给代理之前将其强制返回到允许的 2-10 范围内。接下来,我们将使用一个函数将代理的原始输出重塑为用户界面期望的 PlatformDraft 形状:
function buildDraft(
平台Platform、
content: string、
hashtags: string[]、
segments: string[] | undefined、
format:PostFormat、
):PlatformDraft {
const spec = PLATFORM_SPECS[平台];
const wantsThread = format === "thread" && isThreadable(platform);
const hasSegments = Array.isArray(segments) && segments.length > 1;
if (wantsThread && hasSegments && segments) {
const joined = segments.join("/n/n");
返回 {
平台、
content: joined、
segments、
hashtags、
charLimit: spec.charLimit、
charCount:0, // 线程在用户界面中跟踪每个片段的计数
};
}
返回 {
平台、
内容、
标签、
charLimit: spec.charLimit、
charCount: content.length、
};
}
对于线程,我们将分段连接到 content 中,为用户界面保留分段数组,并将 charCount 设置为 0 - 每个分段都有自己的每条帖子限制。两个助手将构建我们发送给代理的提示的每个请求部分:
function formatLabel(
platform:Platform、
format:PostFormat、
threadLength: number、
): string {
if (format === "thread" && isThreadable(platform)) {
return `thread of ${threadLength} posts (each <=${PLATFORM_SPECS[platform].charLimit} chars)`;
}
return `single post (<=${PLATFORM_SPECS[platform].charLimit} chars)`;
}
function buildPlatformBrief(
platforms:Platform[]、
format:PostFormat、
threadLength: number、
): string {
返回平台
.map((p) => {
const spec = PLATFORM_SPECS[p];
return `- ${p}- ${spec.label} - format: ${formatLabel(p, format, threadLength)}`;
})
.join("\n");
}
buildPlatformBrief 为每个选定平台调用一次 formatLabel 并将结果合并为一个文本块。对于包含 LinkedIn 和 X-as-a-thread-of-4 的请求,输出如下:
- linkedin - LinkedIn - format: single post (<=3000 chars)
- x - X(Twitter)- 格式:由 4 个帖子组成的线程(每个 <=280 字符)
在我们调用代理之前,该代码块会插值到 previewPosts 中的用户提示中。系统提示(agent.ts 中)保持不变;根据请求而改变的部分(如此摘要)将进入用户提示。一个类型声明反映了代理返回的内容:
type AgentOutput = {
文章ArticlePreview;
草稿:Array<{
platform:platform;
content: string;
segment?
hashtags: string[];
}>;
};
现在是主要服务器操作 - previewPosts,它接收表单输入并返回每个选定平台的草稿:
export async function previewPosts(params:{
url: string;
tone:Tone;
platforms:Platform[];
format:PostFormat;
threadLength: number;
}):Promise<PreviewResult> {
const { url, tone, platforms, format }= params;
const threadLength = clampThreadLength(params.threadLength);
if (platforms.length === 0) {
throw new Error("至少选择一个平台");
}
const runner = await ensureDraftRunner();
const prompt = `为这篇文章的每个请求平台生成一个社交媒体草稿。
使用 web_fetch 抓取的 URL:${url}
音调:${tone}
请求的平台和格式:
${buildPlatformBrief(platforms, format, threadLength)}
准确返回 ${platforms.length} 草稿 ${platforms.length === 1 ? "" : "s"}- 上面列出的每个平台一个。请勿超过任何平台的每篇帖子字符数限制。对于 "thread "格式的平台,返回一个包含完全 ${threadLength} 帖子的数组;
const result = (await runner.ask(prompt)) as AgentOutput;
const selected = new Set(platforms);
const drafts:PlatformDraft[] = result.drafts
.filter((d) => selected.has(d.platform))
.map((d) =>;
.buildDraft(d.platform, d.content, d.hashtags, d.segments, format)、
);
return { article: result.article, drafts };
}
流程如下:ensureDraftRunner()获取单例;我们使用通过 buildPlatformBrief 注入的运行时变量构建提示,runner.ask(prompt)调用代理,然后在返回之前使用 buildDraft 重塑每个草稿。.filter是防御性的--如果模型返回的草稿是用户未选择的平台,我们将放弃该草稿,而不是显示意想不到的内容。而 regenerateDraft 也是针对单个平台执行相同的操作(由每张卡的 Rewrite 按钮调用):
export async function regenerateDraft(params:{
url: string;
platform:Platform;
tone:Tone;
format:PostFormat;
threadLength: number;
}):Promise<PlatformDraft> {
const { url, platform, tone, format }= params;
const threadLength = clampThreadLength(params.threadLength);
const runner = await ensureDraftRunner();
const spec = PLATFORM_SPECS[平台];
const wantsThread = format === "thread" && isThreadable(platform);
const prompt = `使用 web_fetch 阅读本文,然后为"${platform}"生成一个草稿。
URL:${url}
音调: ${tone}
平台和格式:
- 平台: ${platform} - ${spec.label} - 格式: ${formatLabel(platform, format, threadLength)}
返回包含文章块和"${platform}"的一个草稿的 JSON 文件。尝试一个新的角度或钩子,让人感觉这与典型的首次尝试不同。请勿超过每篇文章的字符数限制。${
希望主题
?返回一个完全包含 ${threadLength} 帖子的数组。
: ""
}`;
const result = (await runner.ask(prompt)) as AgentOutput;
const match = result.drafts.find((d) => d.platform === platform);
if (!match) {
throw new Error(`Agent did not return a draft for platform: ${platform}`);
}
return buildDraft(match.platform, match.content, match.hashtags, match.segments, format);
}
与 previewPosts 的形式相同--获取运行程序、构建提示、调用代理、重塑--但仅限于一个平台,并具有 "尝试新角度 "的提示,因此重新生成实际上看起来与第一次尝试不同。在我们继续之前,有一种模式值得命名:提示构成。系统提示(在 agent.ts 中)描述了代理的稳定行为。这些操作中的用户提示包含每次调用都会改变的部分--字符限制、线程长度和平台选择。保存文件,重启开发服务器,然后访问 http://localhost:3000 。粘贴博客 URL,选择语气和一些平台,然后单击草稿。4-8 秒后,草稿就会显示出来。代理成功了。在继续之前,请进行一次快速测试。在其中一个草稿上点击Rewrite,等待,再点击,再等待。每次点击所需的时间大致与第一代相同,为 4-8 秒。这就是我们的基准--请记住这个数字。
Pattern 1:使用工具生命周期回调编写自定义 TypeScript 插件
现在,让我们来谈谈问题所在。每次生成(包括再生)都会调用 web_fetch 工具。每次 web_fetch 调用都会重新下载文章。一个诱人的解决方案是在服务器动作中缓存文章文本,并手动将其传递给代理。这虽然有效,但却给操作带来了第二项工作--它不再仅仅是一个请求处理程序,同时也是一个缓存管理器。ADK-TS 为您提供了一种更简洁的方法:带有 生命周期回调的 插件。插件是一个类,可挂钩到代理执行过程中的特定点。对于工具调用,需要了解两个回调:将它们组合在一起,您就可以拦截任何工具调用并决定接下来会发生什么。这将在实际项目中释放的功能的简短列表: