Skip to content

结构化输出 ​

使用自定义 UI 组件渲染结构化 Agent 响应,替代普通纯文本

结构化输出使 Agent 能够返回类型化的、机器可读的数据,而不是普通纯文本。你不再需要渲染单个字符串,而是会获得一个结构化对象,可以将其映射到任何 UI:卡片、表格、图表、逐步拆解或特定领域的渲染器。

什么是结构化输出? ​

Agent 不是返回自由格式的文本响应,而是使用工具调用(tool call)返回符合预定义 Schema 的结构化对象。这为你带来了:

  • 类型安全的数据:将响应解析为已知的 TypeScript 类型
  • 精确的渲染控制:使用各自专属的 UI 呈现方式来渲染每个字段
  • 一致的格式:无论底层模型如何,每个响应都遵循相同的结构

Agent 通过调用一个“结构化输出”工具来实现这一点,该工具的参数包含响应数据。工具本身不执行任何逻辑,纯粹是返回类型化数据的载体。

使用场景 ​

  • 产品对比:特性对比表、优缺点列表、评分
  • 数据分析:包含指标、细分项和重点摘要的概览
  • 分步指南:包含描述和代码片段的有序指引
  • 食谱:食材、步骤、用时和营养信息
  • 数学与科学:使用 LaTeX 渲染的公式、逐步推导过程
  • 旅行规划:包含日期、地点和费用估算的行程单

定义 Schema ​

为 Agent 返回的结构化数据定义一个 TypeScript 类型。该 Schema 的形态决定了你如何渲染 UI。

以下是内嵌 Demo 所使用的数学解题 Schema:

ts
interface MathSolution {
  problem: string; // 原始数学题目
  steps: {
    explanation: string;
    latex: string; // 此步骤的可选 LaTeX 显示公式
  }[]; // 逐步推导过程
  finalAnswer: string; // 纯文本最终答案
  finalAnswerLatex: string; // 最终答案的 LaTeX 表示
}

你的 Schema 可以是任何形式。无论形态如何,该模式的工作方式都是相同的。

从消息中提取结构化输出 ​

结构化输出位于最后一条 AIMessage 的 tool_calls 数组中。通过找到该 AI 消息并访问第一个工具调用的参数来提取它:

ts
import { AIMessage } from "langchain";

function extractStructuredOutput<T>(messages: any[]): T | null {
  const aiMessage = messages.find(AIMessage.isInstance);
  const toolCall = aiMessage?.tool_calls?.[0];
  if (!toolCall) return null;

  return toolCall.args as T;
}

NOTE

在 Agent 完成流式传输之前,结构化输出工具调用可能尚未填充 args。在流式传输期间,args 可能是部分填充的或为 undefined。在渲染之前请务必检查完整性。

设置 useStream ​

将 useStream 连接到你的结构化输出 Agent,然后读取 stream.messages 并从最新的 AIMessage 工具调用中提取类型化负载(payload)。一旦 args 完整即可渲染你的自定义 UI;在 stream.isLoading 为 true 时显示加载状态(工具参数可能会逐步流式传入),并使用 stream.submit() 发送下一个 Prompt。

NOTE

代码示例使用 useStream<typeof myAgent> 来实现类型安全的流状态。请参阅适用于 Python 或 JavaScript 后端的类型推断(Type inference)。

tsx
import { useStream } from "@langchain/react";
import { AIMessage } from "langchain";

function MathSolutionChat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "structured_output_latex",
  });

  const solution = extractStructuredOutput<MathSolution>(stream.messages);

  return (
    <div>
      {!solution && !stream.isLoading && (
        <PromptInput onSubmit={(text) =>
          stream.submit({ messages: [{ type: "human", content: text }] })
        } />
      )}
      {stream.isLoading && <LoadingIndicator />}
      {solution && <SolutionCard solution={solution} />}
    </div>
  );
}
vue
<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { AIMessage } from "langchain";
import { computed } from "vue";

const stream = useStream<typeof myAgent>({
  apiUrl: "http://localhost:2024",
  assistantId: "structured_output_latex",
});

const solution = computed(() =>
  extractStructuredOutput<MathSolution>(stream.messages.value)
);

function handleSubmit(text: string) {
  stream.submit({ messages: [{ type: "human", content: text }] });
}
`&lt;/script&gt;`

<template>
  <div>
    <PromptInput v-if="!solution && !stream.isLoading" @submit="handleSubmit" />
    <LoadingIndicator v-if="stream.isLoading" />
    <SolutionCard v-if="solution" :solution="solution" />
  </div>
</template>
svelte
<script lang="ts">
  import { useStream } from "@langchain/svelte";
  import { AIMessage } from "langchain";

  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "structured_output_latex",
  });

  const solution = $derived(extractStructuredOutput<MathSolution>(stream.messages));

  function handleSubmit(text: string) {
    stream.submit({ messages: [{ type: "human", content: text }] });
  }
`&lt;/script&gt;`

<div>
  {#if !solution && !stream.isLoading}
    <PromptInput on:submit={(e) => handleSubmit(e.detail)} />
  {/if}
  {#if stream.isLoading}
    <LoadingIndicator />
  {/if}
  {#if solution}
    <SolutionCard {solution} />
  {/if}
</div>
ts
import { Component, computed } from "@angular/core";
import { injectStream } from "@langchain/angular";

@Component({
  selector: "app-math-solution-chat",
  template: `
    @if (!solution() && !stream.isLoading()) {
      <prompt-input (onSubmit)="handleSubmit($event)" />
    }
    @if (stream.isLoading()) {
      <loading-indicator />
    }
    @if (solution()) {
      <solution-card [solution]="solution()" />
    }
  `,
})
export class MathSolutionChatComponent {
  stream = injectStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "structured_output_latex",
  });

  solution = computed(() =>
    extractStructuredOutput<MathSolution>(this.stream.messages())
  );

  handleSubmit(text: string) {
    this.stream.submit({
      messages: [{ type: "human", content: text }],
    });
  }
}

渲染结构化数据 ​

一旦获得了类型化对象,就可以构建一个组件将每个字段映射到相应的 UI 元素。这是该模式的核心:将结构化数据转换为专为其构建的定制界面。

tsx
function LatexBlock({ latex }: { latex: string }) {
  return <div className="latex-block">{latex}</div>; // 使用 KaTeX 或 MathJax 进行渲染。
}

function SolutionCard({ solution }: { solution: MathSolution }) {
  return (
    <div className="solution-card">
      <h3>{solution.problem}</h3>
      <ol>
        {solution.steps.map((step, i) => (
          <li key={i}>
            <span>{step.explanation}</span>
            {step.latex && <LatexBlock latex={step.latex} />}
          </li>
        ))}
      </ol>
      <strong>{solution.finalAnswer}</strong>
      {solution.finalAnswerLatex && <LatexBlock latex={solution.finalAnswerLatex} />}
    </div>
  );
}

处理流式传输中的部分数据 ​

在流式传输过程中,工具调用的参数可能是不完整的 JSON。在你的提取逻辑中需要对此进行防范:

ts
function extractStructuredOutput<T>(
  messages: any[],
  requiredFields: string[] = [],
): T | null {
  const aiMessages = messages.filter(AIMessage.isInstance);
  if (aiMessages.length === 0) return null;

  const lastAI = aiMessages[aiMessages.length - 1];
  const toolCall = lastAI.tool_calls?.[0];
  if (!toolCall?.args) return null;

  const args = toolCall.args as Record<string, unknown>;
  const hasRequired = requiredFields.every(
    (field) => args[field] !== undefined
  );

  if (requiredFields.length > 0 && !hasRequired) return null;
  return args as T;
}

使用 requiredFields 参数等待关键字段填充完毕后再进行渲染:

ts
const solution = extractStructuredOutput<MathSolution>(stream.messages, [
  "problem",
  "steps",
  "finalAnswer",
]);

在流式传输期间进行渐进式渲染 ​

无需等待完整的结构化输出,字段一旦到达即可进行渲染。这可以在 Agent 仍在生成时为用户提供即时反馈:

tsx
function ProgressiveSolutionCard({ messages }: { messages: any[] }) {
  const partial = extractStructuredOutput<Partial<MathSolution>>(messages);
  if (!partial) return null;

  return (
    <div className="solution-card">
      {partial.problem && <h3>{partial.problem}</h3>}

      {partial.steps && partial.steps.length > 0 && (
        <div className="solution-steps">
          <h4>Steps</h4>
          {partial.steps.map((step, i) => (
            <div key={i} className="step">
              <div className="step-number">Step {i + 1}</div>
              <p>{step.explanation}</p>
              {step.latex && <LatexBlock latex={step.latex} />}
            </div>
          ))}
        </div>
      )}

      {partial.finalAnswer && <strong>{partial.finalAnswer}</strong>}
    </div>
  );
}

TIP

当 Schema 具有自然的自上而下顺序时,渐进式渲染效果很好:题目、推导步骤、最终答案。Agent 通常按照 Schema 顺序生成字段,因此 UI 会自然地逐步填充。

最佳实践 ​

  • 渲染前进行校验:在渲染前始终检查必需字段是否存在,因为流式传输可能会传递部分数据
  • 使用通用提取函数:使用类型和必需字段对提取逻辑进行参数化,以便它适用于不同的 Schema
  • 渐进式渲染:在字段到达时立即显示,而不是等待完整对象,从而让用户获得即时反馈
  • 提供后备表示(Fallback):如果某个字段支持富文本渲染(LaTeX、Markdown、图表),也在你的 Schema 中包含等效的纯文本作为兜底
  • 尽可能保持 Schema 扁平:深度嵌套的 Schema 更难进行渐进式渲染,且在部分流式传输时更容易出错
  • 让 UI 与数据匹配:选择最能代表每种字段类型的渲染策略(数组用表格、嵌套对象用卡片、状态字段用徽章)