Tambo 生成式 UI 实现原理

本文整理对 Tambo 生成式 UI 的讨论, 重点解释组件如何变成大模型可调用的工具, 数据如何在前后端流转, 组件如何绑定接口数据, 以及后续对已生成组件的修改是如何发生的。

核心结论:

  • Tambo 不是让大模型直接输出 JSX.
  • Tambo 是把已注册 React 组件转换成 LLM tool, 工具名类似 show_component_WeatherCard.
  • 大模型调用这个 UI tool, tool arguments 就是组件 props.
  • 后端把 tool arguments 的流式 JSON 转成 tambo.component.* 事件.
  • 前端把事件累计成 message.content[type="component"], 再用 ComponentRenderer 渲染真实 React 组件.

整体链路:

React 组件 + propsSchema
  -> TamboRegistryProvider 注册到 componentList
  -> client 发送 availableComponents[]
  -> API 转成内部 AvailableComponent
  -> backend 转成 show_component_* UI tools
  -> LLM 调用 UI tool, 并流式生成 JSON props
  -> ComponentStreamTracker 生成 tambo.component.* 事件
  -> SSE / AG-UI stream 发给前端
  -> event accumulator 写入 message.content[type="component"]
  -> ComponentRenderer 查 registry
  -> React.createElement(Component, props)

1. 组件清单会变成工具, 不是普通提示词

开发者先注册一个普通 React 组件:

import { z } from "zod";

const WeatherCardPropsSchema = z.object({
  city: z.string().describe("City name to display, e.g. Tokyo"),
  temperature: z.number().describe("Temperature in Celsius"),
  condition: z.string().describe("Weather condition"),
  humidity: z.number().optional().describe("Humidity percentage"),
});

type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;

function WeatherCard({
  city,
  temperature,
  condition,
  humidity,
}: WeatherCardProps) {
  return (
    <section>
      <h2>{city}</h2>
      <p>
        {temperature} C, {condition}
      </p>
      {humidity !== undefined && <p>Humidity: {humidity}%</p>}
    </section>
  );
}

注册到 TamboProvider:

<TamboProvider
  components={[
    {
      name: "WeatherCard",
      description:
        "Displays current weather for a city. Use when the user asks about weather.",
      component: WeatherCard,
      propsSchema: WeatherCardPropsSchema,
    },
  ]}
>
  <App />
</TamboProvider>

注册后, 前端 registry 里大致保存:

componentList["WeatherCard"] = {
  name: "WeatherCard",
  description: "Displays current weather for a city...",
  component: WeatherCard,
  props: WeatherCardPropsJsonSchema,
  contextTools: [],
};

这里有一个关键点: 真实的 WeatherCard React 函数只保存在前端. 后端不会拿到 React 组件代码, 只会拿到组件元数据:

{
  "name": "WeatherCard",
  "description": "Displays current weather for a city...",
  "propsSchema": {
    "type": "object",
    "properties": {
      "city": { "type": "string" },
      "temperature": { "type": "number" },
      "condition": { "type": "string" },
      "humidity": { "type": "number" }
    },
    "required": ["city", "temperature", "condition"]
  }
}

后端再把这个组件转成 LLM function tool:

{
  "type": "function",
  "function": {
    "name": "show_component_WeatherCard",
    "description": "Show the WeatherCard UI component the user...",
    "strict": true,
    "parameters": {
      "type": "object",
      "properties": {
        "city": { "type": "string" },
        "temperature": { "type": "number" },
        "condition": { "type": "string" },
        "humidity": {
          "anyOf": [{ "type": "null" }, { "type": "number" }]
        }
      },
      "required": ["city", "temperature", "condition", "humidity"],
      "additionalProperties": false
    }
  }
}

所以模型不是输出:

<WeatherCard city="Tokyo" temperature={24} />

而是调用工具:

{
  "tool": "show_component_WeatherCard",
  "arguments": {
    "city": "Tokyo",
    "temperature": 24,
    "condition": "Cloudy",
    "humidity": 63
  }
}

提示词仍然有作用: 它会告诉模型 show_component_* 是 UI tools, 调用后会在用户屏幕上显示组件. 但组件清单本身的可执行约束主要来自 tool definitions, 包括组件名, 描述和 props schema.


2. 一次生成式 UI 的完整数据流

以用户输入为例:

What's the weather in Tokyo?

步骤 1: 前端发送消息和组件清单

前端发送给 API 的数据大致是:

{
  "message": {
    "role": "user",
    "content": "What's the weather in Tokyo?"
  },
  "availableComponents": [
    {
      "name": "WeatherCard",
      "description": "Displays current weather for a city...",
      "propsSchema": {
        "type": "object",
        "properties": {
          "city": { "type": "string" },
          "temperature": { "type": "number" },
          "condition": { "type": "string" },
          "humidity": { "type": "number" }
        },
        "required": ["city", "temperature", "condition"]
      }
    }
  ],
  "tools": []
}

步骤 2: 后端转成 UI tool

后端把 WeatherCard 转成 show_component_WeatherCard 工具.

availableComponents[]
  -> convertComponentsToUITools()
  -> show_component_WeatherCard

步骤 3: 模型选择工具并生成 props

模型看到用户问天气, 也看到有一个能显示天气的 UI tool, 于是调用:

{
  "tool": "show_component_WeatherCard",
  "arguments": {
    "city": "Tokyo",
    "temperature": 24,
    "condition": "Cloudy",
    "humidity": 63
  }
}

步骤 4: 后端把 tool arguments 转成组件事件

因为 tool name 以 show_component_ 开头, 后端不会把它当普通工具暴露给前端, 而是转成组件流事件:

{
  "type": "CUSTOM",
  "name": "tambo.component.start",
  "value": {
    "messageId": "msg_1",
    "componentId": "comp_1",
    "componentName": "WeatherCard"
  }
}

然后不断发送 props 增量:

{
  "type": "CUSTOM",
  "name": "tambo.component.props_delta",
  "value": {
    "componentId": "comp_1",
    "operations": [
      { "op": "add", "path": "/city", "value": "Tokyo" }
    ],
    "streamingStatus": {
      "city": "started"
    }
  }
}

结束时:

{
  "type": "CUSTOM",
  "name": "tambo.component.end",
  "value": {
    "componentId": "comp_1",
    "finalProps": {
      "city": "Tokyo",
      "temperature": 24,
      "condition": "Cloudy",
      "humidity": 63
    }
  }
}

步骤 5: 前端把事件累计成 message content

前端收到 tambo.component.start 后, 在 assistant message 里创建一个 component block:

{
  "type": "component",
  "id": "comp_1",
  "name": "WeatherCard",
  "props": {},
  "streamingState": "started"
}

收到 props_delta 后应用 JSON Patch:

{
  "type": "component",
  "id": "comp_1",
  "name": "WeatherCard",
  "props": {
    "city": "Tokyo",
    "temperature": 24
  },
  "streamingState": "streaming"
}

收到 end 后:

{
  "type": "component",
  "id": "comp_1",
  "name": "WeatherCard",
  "props": {
    "city": "Tokyo",
    "temperature": 24,
    "condition": "Cloudy",
    "humidity": 63
  },
  "streamingState": "done"
}

步骤 6: ComponentRenderer 渲染真实 React 组件

消息渲染器遇到 component block:

<ComponentRenderer
  key={content.id}
  content={content}
  threadId={threadId}
  messageId={message.id}
  fallback={<div>Unknown component: {content.name}</div>}
/>

ComponentRenderer 做的事情:

content.name = "WeatherCard"
  -> 从前端 registry 查 WeatherCard React component
  -> 解析 content.props
  -> 校验 props schema
  -> React.createElement(WeatherCard, props)

3. 数据由谁获取: 后端取数还是前端组件取数

以天气为例, 有两种常见模式.

模式 A: 前端工具调用接口, 结果交给模型, 再生成组件 props

工具定义在前端:

const getWeatherTool = {
  name: "get_weather",
  description: "Fetch current weather for a city",
  tool: async ({ city }: { city: string }) => {
    const res = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
    if (!res.ok) throw new Error(`Failed to fetch weather: ${res.status}`);
    return await res.json();
  },
  inputSchema: z.object({
    city: z.string().describe("City name"),
  }),
  outputSchema: z.object({
    city: z.string(),
    temperature: z.number(),
    condition: z.string(),
  }),
};

数据流:

用户: What's the weather in Tokyo?
  -> 后端 LLM 决定调用 get_weather({ city: "Tokyo" })
  -> 前端执行 get_weather, 本地 fetch /api/weather
  -> 前端把 tool_result 发回后端
  -> LLM 看到 weather 数据
  -> LLM 调 show_component_WeatherCard({ city, temperature, condition })
  -> 前端渲染 WeatherCard

这个模式适合:

  • 模型需要根据接口返回做判断.
  • 模型需要总结数据.
  • 模型要根据数据选择组件或生成图表 props.

例如:

如果东京下雨, 就推荐室内活动; 如果不下雨, 推荐户外活动.

这种场景模型必须看到天气数据, 所以应该使用工具取数.

模式 B: 组件自己调用接口, 模型只生成查询参数

组件 props 只包含查询参数:

const WeatherCardPropsSchema = z.object({
  city: z.string().describe("City to load weather for"),
});

type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;

组件内部 fetch:

function WeatherCard({ city }: WeatherCardProps) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["weather", city],
    queryFn: async () => {
      const res = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
      if (!res.ok) throw new Error(`Failed to fetch weather: ${res.status}`);
      return await res.json();
    },
    enabled: !!city,
  });

  if (isLoading) return <WeatherSkeleton />;
  if (error) return <WeatherError city={city} />;

  return <WeatherCardView data={data} />;
}

数据流:

用户: What's the weather in Tokyo?
  -> LLM 调 show_component_WeatherCard({ city: "Tokyo" })
  -> 后端只流式返回组件事件和 city prop
  -> 前端 ComponentRenderer 渲染 WeatherCard
  -> WeatherCard 自己 fetch /api/weather?city=Tokyo
  -> React Query / SWR / 组件状态绑定接口数据
  -> UI 更新

这个模式适合:

  • 模型只需要决定展示哪个组件和查询参数.
  • 数据量大, 不适合塞进模型上下文.
  • 数据敏感, 不希望模型看到完整数据.
  • 数据需要实时刷新.
  • 组件本身就是一个数据视图, 例如订单详情, 股票图表, 用户 profile.

4. 模式 B 下接口是动态的怎么办

如果某个组件调用的接口不是写死的, 不建议让模型直接生成任意 URL. 更稳的做法是让模型生成业务语义, 例如 source, resourceId, filters, 组件或后端再把它解析成真实接口.

方案 1: props 传 dataSourceId, 组件内部白名单映射

schema:

const WeatherCardPropsSchema = z.object({
  source: z
    .enum(["currentWeather", "weatherForecast", "airQuality"])
    .describe("Which weather data source to use"),
  city: z.string().describe("City name"),
});

组件:

type WeatherCardProps = z.infer<typeof WeatherCardPropsSchema>;

const weatherFetchers = {
  currentWeather: async ({ city }: { city: string }) => {
    const res = await fetch(
      `/api/weather/current?city=${encodeURIComponent(city)}`,
    );
    if (!res.ok) throw new Error("Failed to fetch current weather");
    return await res.json();
  },

  weatherForecast: async ({ city }: { city: string }) => {
    const res = await fetch(
      `/api/weather/forecast?city=${encodeURIComponent(city)}`,
    );
    if (!res.ok) throw new Error("Failed to fetch forecast");
    return await res.json();
  },

  airQuality: async ({ city }: { city: string }) => {
    const res = await fetch(
      `/api/weather/air-quality?city=${encodeURIComponent(city)}`,
    );
    if (!res.ok) throw new Error("Failed to fetch air quality");
    return await res.json();
  },
} as const;

function WeatherCard({ source, city }: WeatherCardProps) {
  const fetcher = weatherFetchers[source];

  const { data, isLoading, error } = useQuery({
    queryKey: ["weather-card", source, city],
    queryFn: async () => fetcher({ city }),
  });

  if (isLoading) return <WeatherCardSkeleton />;
  if (error) return <WeatherCardError city={city} />;

  return <WeatherCardView data={data} source={source} />;
}

数据流:

用户: What's the weather in Tokyo?
  -> LLM 调 show_component_WeatherCard({
       source: "currentWeather",
       city: "Tokyo"
     })
  -> 前端渲染 WeatherCard
  -> WeatherCard 从 weatherFetchers.currentWeather 找到 fetcher
  -> fetch /api/weather/current?city=Tokyo
  -> data 绑定到 UI

优点是模型只能在 enum 允许的数据源里选, 不会随便编 URL.

方案 2: 不同接口参数不同, 使用 discriminated union

如果不同数据源参数不一样:

const WeatherCardPropsSchema = z.discriminatedUnion("source", [
  z.object({
    source: z.literal("currentWeather"),
    city: z.string(),
  }),

  z.object({
    source: z.literal("weatherForecast"),
    city: z.string(),
    days: z.number().min(1).max(10),
  }),

  z.object({
    source: z.literal("weatherByCoordinates"),
    lat: z.number(),
    lon: z.number(),
  }),
]);

组件:

function WeatherCard(props: WeatherCardProps) {
  const query = useQuery({
    queryKey: ["weather-card", props],
    queryFn: async () => {
      switch (props.source) {
        case "currentWeather":
          return fetchCurrentWeather({ city: props.city });

        case "weatherForecast":
          return fetchWeatherForecast({
            city: props.city,
            days: props.days,
          });

        case "weatherByCoordinates":
          return fetchWeatherByCoordinates({
            lat: props.lat,
            lon: props.lon,
          });
      }
    },
  });

  return <WeatherCardView result={query} source={props.source} />;
}

如果不同 source 的 UI 差异很大, 不要把所有逻辑塞进一个大组件, 直接拆成多个组件:

CurrentWeatherCard
ForecastCard
AirQualityCard

然后让模型选择组件.

方案 3: 接口由页面, 租户, 环境决定, 用 Context 注入

如果接口不是模型选择, 而是当前用户, workspace, tenant 或环境决定, 不要把接口放进 props. 用 React context 注入 fetcher.

interface DataSourceRegistry {
  fetchWeather: (input: { city: string }) => Promise<WeatherData>;
}

const DataSourceContext = createContext<DataSourceRegistry | null>(null);

function useDataSources() {
  const value = useContext(DataSourceContext);
  if (!value) throw new Error("DataSourceContext is missing");
  return value;
}

页面层:

<DataSourceContext.Provider
  value={{
    fetchWeather: async ({ city }) => {
      const res = await fetch(
        `/api/tenant/${tenantId}/weather?city=${encodeURIComponent(city)}`,
      );
      if (!res.ok) throw new Error("Failed to fetch tenant weather");
      return await res.json();
    },
  }}
>
  <TamboProvider components={[weatherCardComponent]}>
    <App />
  </TamboProvider>
</DataSourceContext.Provider>

组件:

function WeatherCard({ city }: { city: string }) {
  const { fetchWeather } = useDataSources();

  const query = useQuery({
    queryKey: ["weather", city],
    queryFn: async () => fetchWeather({ city }),
  });

  return <WeatherCardView result={query} />;
}

数据流:

用户消息
  -> LLM 只生成 { city: "Tokyo" }
  -> WeatherCard 渲染
  -> WeatherCard 从 Context 拿当前租户/页面注入的 fetchWeather
  -> 调动态接口
  -> UI 更新

方案 4: 组件只拿 resourceId, 真实接口由后端解析

如果接口涉及密钥, 权限, 跨域, 用户私有数据, 组件应调用固定后端代理, 由后端解析真实动态接口.

schema:

const WeatherCardPropsSchema = z.object({
  resourceId: z.enum(["weather.current", "weather.forecast"]),
  city: z.string(),
});

组件:

function WeatherCard({ resourceId, city }: WeatherCardProps) {
  const query = useQuery({
    queryKey: ["resource", resourceId, city],
    queryFn: async () => {
      const res = await fetch("/api/data-source/query", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ resourceId, params: { city } }),
      });

      if (!res.ok) throw new Error("Failed to query data source");
      return await res.json();
    },
  });

  return <WeatherCardView result={query} />;
}

数据流:

LLM 生成 resourceId + params
  -> 前端组件 POST /api/data-source/query
  -> 后端校验权限
  -> 后端调用真实动态接口
  -> 返回数据
  -> 组件渲染

5. 能不能对大模型说: 天气接口是 /api/weather/xxx

可以, 但要分清楚: 只在 prompt 里告诉大模型接口地址, 不会自动让组件去调用接口. 组件必须有代码使用这个信息.

如果组件 schema 只有:

propsSchema: z.object({
  city: z.string(),
})

你在 prompt 里说:

天气调用的接口是 /api/weather/xxx

模型最多知道这个信息, 但渲染时 WeatherCard 还是只拿到:

{ "city": "Tokyo" }

它不会凭空调用接口, 除非组件实现里写了 fetch 逻辑.

可以把接口路径变成受控 props:

const WeatherCardPropsSchema = z.object({
  apiPath: z.enum(["/api/weather/current", "/api/weather/forecast"]),
  city: z.string(),
});

组件:

function WeatherCard({ apiPath, city }: WeatherCardProps) {
  const query = useQuery({
    queryKey: ["weather", apiPath, city],
    queryFn: async () => {
      const res = await fetch(`${apiPath}?city=${encodeURIComponent(city)}`);
      if (!res.ok) throw new Error("Failed to fetch weather");
      return await res.json();
    },
  });

  return <WeatherCardView result={query} />;
}

模型可以调用:

{
  "tool": "show_component_WeatherCard",
  "arguments": {
    "apiPath": "/api/weather/current",
    "city": "Tokyo"
  }
}

但更推荐让模型传业务 ID, 而不是直接传 URL:

const WeatherCardPropsSchema = z.object({
  source: z.enum(["current", "forecast"]),
  city: z.string(),
});

组件里映射:

const weatherApiPaths = {
  current: "/api/weather/current",
  forecast: "/api/weather/forecast",
} as const;

function WeatherCard({ source, city }: WeatherCardProps) {
  const apiPath = weatherApiPaths[source];
  // fetch apiPath...
}

这样更稳:

  • 模型只能选允许的接口.
  • 不会编出错误路径.
  • 不会生成危险路径.
  • 不会把底层网络细节暴露为模型可自由控制的输入.

6. 流式渲染和增量编译

这里要区分两个概念:

流式渲染 props: Tambo 支持.
运行时增量编译 JSX/React 代码: Tambo 当前不是这个架构, 也不建议.

6.1 流式渲染 props

模型调用 show_component_WeatherCard 后, tool arguments JSON 是分片返回的:

{"city":"Tokyo"
,"temperature":24
,"condition":"Cloudy"
,"humidity":63}

后端会把分片 JSON 转成 JSON Patch:

[
  { "op": "add", "path": "/city", "value": "Tokyo" }
]

后续:

[
  { "op": "add", "path": "/temperature", "value": 24 }
]

再后续:

[
  { "op": "add", "path": "/condition", "value": "Cloudy" }
]

前端持续应用 patch:

{}

变成:

{ "city": "Tokyo" }

再变成:

{ "city": "Tokyo", "temperature": 24 }

最后变成:

{
  "city": "Tokyo",
  "temperature": 24,
  "condition": "Cloudy"
}

因此组件要能处理 props 尚未完整的状态:

function WeatherCard({ city, temperature, condition }: WeatherCardProps) {
  return (
    <section>
      <h2>{city ?? "Loading city..."}</h2>
      <p>
        {temperature === undefined
          ? "Loading temperature..."
          : `${temperature} C`}
      </p>
      <p>{condition ?? "Loading condition..."}</p>
    </section>
  );
}

6.2 组件内部也可以二次流式取数

如果组件自己调用一个流式接口:

LLM 流式生成 { source, city }
  -> WeatherCard 渲染
  -> WeatherCard 调 /api/weather/stream
  -> 组件内部持续 setState / query update
  -> UI 增量更新

这属于组件内部的数据流, 和 Tambo 的 props streaming 是两层流式.

6.3 不建议运行时增量编译 JSX

如果所谓增量编译是:

LLM 边生成 JSX / React 代码
  -> 前端边编译
  -> 边挂载新组件

那不是 Tambo 当前模式, 也不建议作为默认架构.

原因:

  • 需要浏览器端编译器或 runtime evaluator.
  • 安全风险高.
  • 依赖解析复杂.
  • 类型不可控.
  • 权限边界难管.
  • hydration, bundle, sandbox 都复杂.

Tambo 采用的是:

预编译组件 + 流式 JSON props

如果想要动态 UI 结构, 更推荐使用安全 DSL, 而不是编译代码.

例如注册一个通用组件 DynamicDashboard, 让模型生成受控 UI spec:

const DynamicDashboardPropsSchema = z.object({
  blocks: z.array(
    z.discriminatedUnion("type", [
      z.object({
        type: z.literal("metric"),
        title: z.string(),
        value: z.string(),
      }),
      z.object({
        type: z.literal("chart"),
        title: z.string(),
        source: z.enum(["weather", "sales", "traffic"]),
      }),
    ]),
  ),
});

组件内部解释 DSL:

function DynamicDashboard({ blocks }: DynamicDashboardProps) {
  return (
    <div>
      {blocks.map((block, index) => {
        switch (block.type) {
          case "metric":
            return (
              <MetricCard
                key={index}
                title={block.title}
                value={block.value}
              />
            );

          case "chart":
            return (
              <ChartBlock
                key={index}
                title={block.title}
                source={block.source}
              />
            );
        }
      })}
    </div>
  );
}

这样可以做到模型动态生成页面结构, 但仍然是解释安全 schema, 不是编译 JSX.


7. 后续让大模型修改刚生成的组件

例如第一轮:

用户: 生成一个天气卡片

模型调用:

{
  "tool": "show_component_WeatherCard",
  "arguments": {
    "city": "Tokyo",
    "backgroundColor": "white"
  }
}

前端消息里得到:

{
  "type": "component",
  "id": "comp_1",
  "name": "WeatherCard",
  "props": {
    "city": "Tokyo",
    "backgroundColor": "white"
  },
  "streamingState": "done"
}

第二轮用户说:

把刚生成的 Card 背景色改为红色

这里有两种情况.

情况 A: 普通 generative component

普通生成式组件的语义是: 每条消息生成一个新组件实例.

因此模型通常会重新调用一次:

{
  "tool": "show_component_WeatherCard",
  "arguments": {
    "city": "Tokyo",
    "backgroundColor": "red"
  }
}

线程里会出现新的 component block:

{
  "type": "component",
  "id": "comp_2",
  "name": "WeatherCard",
  "props": {
    "city": "Tokyo",
    "backgroundColor": "red"
  }
}

也就是说, 对普通生成式组件来说:

“改成红色” = 通常新生成一个组件 JSON / 组件实例

而不是原地修改 comp_1.

情况 B: interactable component

如果想原地修改已有 Card, 应把它设计成 interactable.

Interactable component 会给已有组件实例自动注册更新工具:

update_component_props_<componentId>
update_component_state_<componentId>

这时第二轮用户说:

把刚生成的 Card 背景色改为红色

模型可以调用:

{
  "tool": "update_component_props_card_123",
  "arguments": {
    "componentId": "card_123",
    "newProps": {
      "backgroundColor": "red"
    }
  }
}

前端本地更新已有组件实例:

{
  "id": "card_123",
  "props": {
    "city": "Tokyo",
    "backgroundColor": "red"
  }
}

数据流:

已有 Card 实例
  -> interactable provider 注册 update_component_props_card_123
  -> 用户: 改成红色
  -> LLM 调 update_component_props_card_123({ newProps: { backgroundColor: "red" } })
  -> 前端执行 tool
  -> 同一个 Card 实例 props 更新
  -> React 重渲染

所以:

普通生成式组件:
  后续修改 = 通常新生成一个组件 JSON.

可交互组件:
  后续修改 = 调 update_component_props/state 工具, 原地修改已有实例.

如果产品体验需要“修改刚才生成的 UI”, 应把可变字段设计成 props 或 state:

const CardSchema = z.object({
  title: z.string(),
  body: z.string(),
  backgroundColor: z.enum(["white", "red", "blue", "green"]),
});

然后让这个 Card 具备 interactable/update 能力. 这样模型修改的是受控字段, 不是重新编译组件代码.


8. JSON Patch 是部分更新吗

是. JSON Patch 是部分更新, 但要注意它主要用于“当前这一次组件生成过程中的流式 props 增量更新”.

例如模型正在生成:

{
  "city": "Tokyo",
  "temperature": 24,
  "condition": "Cloudy"
}

后端不会等完整 JSON 都出来才发给前端, 而是发 patch:

[
  { "op": "add", "path": "/city", "value": "Tokyo" }
]

然后:

[
  { "op": "add", "path": "/temperature", "value": 24 }
]

再然后:

[
  { "op": "add", "path": "/condition", "value": "Cloudy" }
]

前端把这些 patch 应用到当前 props:

{}

变成:

{ "city": "Tokyo" }

再变成:

{ "city": "Tokyo", "temperature": 24 }

最后变成:

{
  "city": "Tokyo",
  "temperature": 24,
  "condition": "Cloudy"
}

但要分清楚:

tambo.component.props_delta / JSON Patch
  = 当前这一次组件生成过程中的流式 props 增量更新

update_component_props_<id>
  = 后续对一个已有 interactable 组件实例做部分更新

例如:

把刚才那个 Card 背景色改成红色

如果是 interactable, 调用的是:

{
  "componentId": "card_123",
  "newProps": {
    "backgroundColor": "red"
  }
}

这也是部分更新, 但它走的是 update_component_props_ 工具, 不是普通生成式组件那条 tambo.component.props_delta 流式事件.


9. 复杂布局如何实现

复杂布局一般不要靠大模型生成布局代码, 而是靠:

预注册布局组件
  + 受控 JSON 布局 schema
  + 前端 LayoutRenderer / DashboardLayout 解释 schema

核心思路:

LLM 不生成 JSX / CSS
LLM 生成 layout JSON
前端用预注册的 LayoutRenderer 解释 JSON
LayoutRenderer 渲染真实 React 组件

不要让模型输出:

<div className="grid grid-cols-3 gap-4">
  <Card />
  <Chart />
</div>

而是让模型调用一个布局组件:

{
  "tool": "show_component_DashboardLayout",
  "arguments": {
    "title": "Tokyo Weather Overview",
    "layout": "twoColumn",
    "blocks": [
      {
        "type": "metric",
        "title": "Temperature",
        "value": "24 C",
        "intent": "primary"
      },
      {
        "type": "chart",
        "title": "7-day forecast",
        "source": "weatherForecast",
        "query": {
          "city": "Tokyo",
          "days": 7
        },
        "span": 2
      },
      {
        "type": "table",
        "title": "Air quality",
        "source": "airQuality",
        "query": {
          "city": "Tokyo"
        }
      }
    ]
  }
}

前端注册的是一个复杂父组件:

const DashboardLayoutSchema = z.object({
  title: z.string(),
  layout: z.enum(["singleColumn", "twoColumn", "threeColumn"]),
  blocks: z.array(
    z.discriminatedUnion("type", [
      z.object({
        type: z.literal("metric"),
        title: z.string(),
        value: z.string(),
        intent: z.enum(["primary", "success", "warning", "danger"]).optional(),
        span: z.number().optional(),
      }),
      z.object({
        type: z.literal("chart"),
        title: z.string(),
        source: z.enum(["weatherForecast", "salesTrend", "traffic"]),
        query: z.record(z.unknown()),
        span: z.number().optional(),
      }),
      z.object({
        type: z.literal("table"),
        title: z.string(),
        source: z.enum(["airQuality", "orders", "users"]),
        query: z.record(z.unknown()),
        span: z.number().optional(),
      }),
    ]),
  ),
});

组件内部解释 schema:

function DashboardLayout({ title, layout, blocks }: DashboardLayoutProps) {
  return (
    <section>
      <h2>{title}</h2>

      <div className={getGridClass(layout)}>
        {blocks.map((block, index) => (
          <div key={index} className={getSpanClass(block.span)}>
            <DashboardBlock block={block} />
          </div>
        ))}
      </div>
    </section>
  );
}

function DashboardBlock({ block }: { block: DashboardBlock }) {
  switch (block.type) {
    case "metric":
      return (
        <MetricCard
          title={block.title}
          value={block.value}
          intent={block.intent}
        />
      );

    case "chart":
      return (
        <ChartBlock
          title={block.title}
          source={block.source}
          query={block.query}
        />
      );

    case "table":
      return (
        <TableBlock
          title={block.title}
          source={block.source}
          query={block.query}
        />
      );
  }
}

数据流:

用户: 做一个东京天气仪表盘
  -> LLM 调 show_component_DashboardLayout(...)
  -> 后端流式发送 layout props
  -> 前端形成 component content block
  -> ComponentRenderer 渲染 DashboardLayout
  -> DashboardLayout 解释 blocks
  -> MetricCard / ChartBlock / TableBlock 分别渲染
  -> ChartBlock / TableBlock 根据 source + query 调接口

9.1 复杂布局的三种模式

模式一: 组合型大组件

适合业务固定的复杂 UI:

WeatherDashboard
OrderDetailPanel
UserProfileOverview
SalesReport
ComparisonView

模型只负责生成业务 props:

{
  "city": "Tokyo",
  "sections": ["current", "forecast", "airQuality"]
}

组件内部决定布局. 优点是稳定, 缺点是灵活性较低.

模式二: Layout DSL 组件

适合模型动态组合页面的场景:

dashboard
report
analysis page
summary page

模型生成:

{
  "layout": "threeColumn",
  "blocks": [
    { "type": "metric", "title": "Revenue", "value": "$12,300" },
    { "type": "chart", "title": "Trend", "source": "salesTrend" },
    { "type": "table", "title": "Recent Orders", "source": "orders" }
  ]
}

前端解释:

block.type = metric -> MetricCard
block.type = chart  -> ChartBlock
block.type = table  -> TableBlock

这是最推荐的复杂布局方案: 灵活, 但仍然受控.

模式三: 多个 UI tool 连续调用

模型也可以连续调用多个组件:

show_component_SummaryCard
show_component_ForecastChart
show_component_AirQualityTable

这通常会在消息流里形成多个兄弟内容块. 它适合“聊天消息里连续展示多个结果”, 但不适合精确控制网格, 分栏, 嵌套布局.

如果要做真正复杂布局, 应该用一个父级布局组件包起来:

show_component_DashboardLayout({
  layout,
  blocks
})

9.2 复杂布局的约束

不要让模型生成任意 className:

{
  "className": "absolute top-[-999px] ..."
}

不要让模型生成任意 CSS:

{
  "style": "position: fixed; z-index: 999999"
}

不要让模型生成任意 URL:

{
  "url": "/api/weather/forecast?city=Tokyo"
}

不要让模型生成 React 代码:

function CustomComponent() {
  return <div>...</div>;
}

应该让模型只能选受控值:

layout: "singleColumn" | "twoColumn" | "threeColumn";
intent: "primary" | "success" | "warning" | "danger";
source: "weatherForecast" | "salesTrend" | "traffic";
span: 1 | 2 | 3;

接口绑定也应该通过 source + query:

{
  "type": "chart",
  "source": "weatherForecast",
  "query": {
    "city": "Tokyo",
    "days": 7
  }
}

而不是直接让模型控制完整 URL.

9.3 后续修改复杂布局

普通 generative layout:

用户: 把图表放到左边
  -> 重新生成一个新的 DashboardLayout JSON

interactable layout:

用户: 把图表放到左边
  -> 调 update_component_props/state 修改已有 layout

但是 update_component_props_<id> 通常更适合更新顶层 props. 如果要精确修改某个 block, 比如:

把第二个图表改成柱状图

更稳的做法是注册一个领域工具:

{
  "tool": "update_dashboard_block",
  "arguments": {
    "componentId": "dashboard_1",
    "blockId": "forecast",
    "patch": {
      "chartType": "bar"
    }
  }
}

也可以让布局 schema 给每个 block 一个稳定 id:

const DashboardBlockSchema = z.discriminatedUnion("type", [
  z.object({
    id: z.string(),
    type: z.literal("metric"),
    title: z.string(),
    value: z.string(),
  }),
  z.object({
    id: z.string(),
    type: z.literal("chart"),
    title: z.string(),
    source: z.enum(["weatherForecast", "salesTrend"]),
    chartType: z.enum(["line", "bar", "area"]),
  }),
]);

这样后续修改可以基于稳定 blockId, 而不是依赖数组下标.

9.4 复杂布局总结

复杂布局不是让 Tambo 变成页面编译器, 而是注册一个布局解释器组件:

LLM 生成受控 JSON
  -> DashboardLayout 解释 JSON
  -> 映射到 MetricCard / ChartBlock / TableBlock
  -> 每个 block 自己取数或接收 props
  -> React 渲染真实 UI

推荐设计:

父级 Layout component:
  负责 grid / section / tab / column / responsive 规则.

子级 Block components:
  负责具体业务展示.

schema:
  只暴露业务语义和有限布局枚举.

数据接口:
  用 source/resourceId + query 白名单映射.

后续修改:
  普通生成式组件重新生成 JSON.
  interactable 组件用 update tool 原地修改.

10. 架构取舍总结

Tambo 的核心设计

不是: LLM 输出 JSX -> 前端编译 JSX
而是: LLM 调 UI tool -> 输出 JSON props -> 前端渲染预注册组件

组件清单的角色

components[]
  -> availableComponents[]
  -> show_component_* tools

组件清单主要变成工具定义, 不是单纯拼进提示词.

数据获取的选择

模型需要理解数据:
  用 tool 获取数据, tool result 回到模型, 再生成组件 props.

模型只需要决定展示什么:
  组件自己 fetch 数据, 模型只传 source/resourceId/filters.

动态接口的安全做法

不要让模型直接生成任意 URL.
让模型生成 source/resourceId.
前端或后端通过白名单解析真实接口.

流式与更新的区别

JSON Patch:
  一次组件生成过程中的流式 props 增量更新.

update_component_props/state:
  后续对已有 interactable 组件实例的原地部分更新.

重新 show_component_*:
  普通生成式组件的新实例.

推荐模式

对大多数生产场景, 推荐:

预注册组件
  + 严格 props schema
  + source/resourceId 白名单
  + 组件内部 fetch 或后端代理
  + interactable 支持后续修改
  + JSON Patch 支持首轮流式渲染