解决LLM Agent运行在服务器、目标位于浏览器iframe沙箱中无法直接操控的跨隔离边界协同问题,方案将工具声明与执行分离。
我需要让一个 LLM 去控制一个只存在于用户浏览器里的运行时:一个嵌入在沙箱 iframe 中的目标。用户用自然语言描述他们想要什么,一个 agent 找出步骤,然后目标实时更新。
一个问题:agent 运行在我的服务器上,而目标存在于浏览器里。服务器根本无法触碰它。没有 DOM 访问,没有进入 iframe 内部世界的连接。agent 需要控制的东西是一个黑箱,位于 iframe 边界的另一侧。
标准的 AI SDK 模式是:声明工具及其 schema,模型调用它们,服务器执行它们。当你的工具接触数据库、API、文件时,这套机制运转良好。但一旦你的"工具"必须作用于一个只有浏览器才能触及的客户端运行时,它就崩溃了。
方案 A:在服务器上执行,变更某个共享状态,让客户端轮询变更。对一个实时运行时轮询参数变化是很糟糕的。
方案 B:放弃,把所有交互都预建成按钮。无趣,而且无法扩展。
方案 C:分脑模式(split-brain pattern)。这是我的选择。
分脑的思路是:在服务器上声明工具(让模型知道它们的名称、schema 和描述),但在浏览器里执行它们(那里有实际的目标),然后把结果反馈回对话。
模型永远不会知道其中的差异。对它来说,setParameter 就是一个完全正常的工具,返回 { ok: true }。
服务器端,工具用 schema 声明,但故意不提供执行逻辑:
// Server side: the model sees this and knows how to call it.
setParameter: {
description:
'Set a single parameter of the active target, e.g. speed, mode, or a view position.',
inputSchema: z.object({
param: z.string().describe('Parameter name, matching the active target schema (e.g. `speed`).'),
value: z.union([
z.number(),
z.boolean(),
z.string(),
z.array(z.number()).describe('A [x, y, z] number array for view position / look_at.'),
]),
}),
},
客户端,同样的工具在 onToolCall 中被拦截,针对 iframe 执行,然后结果被推回对话:
// Browser side: the tool actually executes here.
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === 'setParameter') {
const { param, value } = toolCall.input;
const ok = applyParameter(param, value); // postMessage into the target iframe
addToolOutput({
tool: 'setParameter',
toolCallId: toolCall.toolCallId,
output: ok
? { ok: true, param, value }
: { ok: false, error: `Parameter "${param}" not found in the active target schema.` },
});
}
},
让这一切天衣无缝的关键:addToolOutput 将结果返回给模型,其方式与服务器执行了它完全一样。模型毫不知情。
有一个微妙之处。当模型发出工具调用时,SDK 会想要暂停并等待结果。但我的工具调用是在浏览器里即时执行的,我希望 agent 一直运行直到整个任务完成:选择一个目标,读取它的 schema,设置三个参数,用自然语言确认。
AI SDK 有 sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,当最后一条消息是一个完整的工具调用轮次时会重新触发一次发送。但有一个陷阱:SDK 期望最后一条消息是用户消息才会发送。所以我注入了一个合成的用户轮次:
prepareSendMessagesRequest: (options) => {
const msgs = options.messages ?? [];
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
const needsUserTurn =
last &&
last.role === 'user' &&
Array.isArray(last.parts) &&
last.parts.some((p) => p.type === 'tool-result');
const finalMessages = needsUserTurn
? [
...msgs,
{
id: `user-proceed-${Date.now()}`,
role: 'user',
parts: [{ type: 'text', text: 'Proceed.' }],
},
]
: msgs;
return { ...options, body: { ...options.body, messages: finalMessages } };
},
当最后一个轮次以工具结果结束时,一个悄无声息的"Proceed."被附加上去,模型继续:检查结果、调用更多工具,或用总结收尾。用户永远看不到它。agent 循环执行直到真正完成。
现在是无聊但关键的部分:iframe 通信。父页面和每个目标通过 postMessage 使用同一种线路格式:
父到目标:{ type: 'apply', payload } 和 { type: 'reset' }
目标到父:{ type: 'ready' } 和 { type: 'state', payload }
还有一个会咬你一口的竞态条件:当 agent 应用第一个参数时,目标的脚本可能还没有执行。发到一个尚未注册监听器的 frame 的消息会被静默丢弃。修复方法是使用一个小队列:
const postToTarget = useCallback((message: object) => {
const frame = iframeRef.current;
if (!frame || !frame.contentWindow || !frame.src || frame.src === 'about:blank') return false;
if (!iframeReadyRef.current) {
pendingMessagesRef.current.push(message); // not ready yet, queue it
return true;
}
frame.contentWindow.postMessage({ source: PARENT_SOURCE, ...message }, origin);
return true;
}, []);
在目标端,一个共享的桥接脚本缓冲在 init 完成前到达的任何内容,然后在它发出就绪信号的瞬间将其 flush:
function handleEvent(event) {
var msg = event && event.data;
if (!msg || typeof msg !== 'object' || msg.source !== PARENT_SOURCE) return;
if (!ready) { buffer.push(msg); return; } // buffer until init completes
dispatch(msg);
}
ready: function () {
// ... flip any stale status badge, then:
ready = true;
var buffered = buffer.splice(0, buffer.length);
for (var i = 0; i < buffered.length; i++) dispatch(buffered[i]);
window.parent.postMessage({ source: TARGET_SOURCE, type: 'ready' }, window.location.origin);
}
如果 agent 在目标加载完成前设置了一个参数,它会进入缓冲区,并在目标就绪的瞬间生效。不会丢失工具调用,不会有"我的请求到底有没有发出?"的疑问。
每个目标都附带一个 JSON manifest,它同时充当配置、参数 schema 和文档。单位包含在内:
{
"id": "preset-01",
"type": "apply",
"data": {
"settings": {
"speed": { "type": "number", "unit": "m/s", "value": 8.0 },
"mode": { "type": "string", "value": "auto" }
},
"display": {
"show_vectors": { "type": "boolean", "value": true }
},
"view": {
"position": { "type": "array", "length": 3, "unit": "world units", "value": [0, 12, 12] }
}
}
}
模型被告知在触碰任何东西之前先调用 getParameters,因为真正的参数名是 spd 而不是 speed,而且值带有单位。工具读取 schema,返回带类型的值,模型从第一性原理推导缺失的值,而不是靠猜。
这就是在实践中让整个机制感觉像魔法一样的部分:添加一个新目标只需要把一个文件及其 schema JSON 丢进一个文件夹。不需要修改应用,不需要修改协议,不需要针对每个目标写代码。Schema 就是契约,模型和目标都从同一个真相来源读取。
永远不要让模型猜参数名。一个返回精确 schema 的 getParameters 工具只需要一次工具调用,就能节省一整轮"参数 foo 未找到"的对话。
{ value } 包装器是每个边界的敌人。一个值是被包装还是裸露取决于产生它的层。在发送端统一规范化一次,在接收端防御性地拆解一次,然后就忘掉它。我的协议两边都防御性地拆解,所以目标不需要自己的逻辑。
免费层的速率限制是真实存在的,SDK 的重试对它们毫无用处。Gemini 免费层返回 429 并带有一个 RetryInfo 块,告诉您确切的重试时间。AI SDK 的默认重试是立即的,这对 40 秒冷却毫无意义。包装 fetch,解析重试延迟,然后真正地 sleep:
fetch: async (input, init) => {
const maxAttempts = 3;
for (let attempt = 0; ; attempt++) {
const res = await fetch(input, init);
if (res.status !== 429 || attempt >= maxAttempts - 1) return res;
const body = await res.text().catch(() => '');
let delayMs = 25000;
try {
const j = JSON.parse(body);
const retryInfo = j?.error?.details?.find(
(d) => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
);
const seconds = parseFloat(retryInfo?.retryDelay?.replace(/[^0-9.]/g, '')) || 0;
if (seconds > 0) delayMs = Math.min(Math.ceil(seconds * 1000) + 2000, 45000);
} catch {}
await new Promise((r) => setTimeout(r, delayMs));
}
},
工具调用循环如果不加控制会消耗你的 token。让模型在操作后用一行简短的自然语言确认改变了什么。它作为状态更新呈现给用户,同时阻止模型无休止地叙述。
分脑模式适用于任何 agent 需要控制服务器无法触及的东西的场景:浏览器扩展、基于 canvas 的渲染器、本地设备、沙箱 iframe、用户自己的机器。在服务器端声明工具,在真正目标所在的地方执行,把结果反馈回去。模型永远不需要知道其中的差异。
这是"谈论你的产品"的 agent 和"驱动你的产品"的 agent 之间的区别。