跳至内容

使用 GPT-4o 生成一致的故事

语言模型在生成具有大量节点的一致图时会遇到困难。通常情况下,这是因为图本身对于模型来说太大了。这会导致模型生成不一致的图,其中包含无效节点和断开连接的节点等问题。

在本文中,我们将通过一个简单的“选择你自己的冒险”故事生成示例,探讨如何使用两阶段方法结合 gpt-4o 来生成复杂的 DAGs,从而克服这一限制。

为什么 DAGs 很重要?

DAGs 是有向无环图。当节点之间的每个连接都是有方向的(单向移动),并且没有循环(不会回到先前的节点)时,该图被视为 DAG。

graph TD
    A --> B
    A --> C
    B --> D
    C --> D

这与“选择你自己的冒险”故事非常相似,在该故事中,用户在每个步骤都有固定的选择集,并且只能在故事中向前推进。我们可以在下面看到实际应用

graph TD
    A[Story Root] --> B[Choice 1]
    A --> C[Choice 2]
    A --> D[Choice 3]
    B --> E[Choice 1.1]
    B --> F[Choice 1.2]
    C --> G[Choice 2.1]
    C --> H[Choice 2.2]
    D --> I[Choice 3.1]
    D --> J[Choice 3.2]

挑战:扩展故事生成

当我们尝试使用语言模型一次性生成一个故事时,很快就会遇到几个限制,因为即使每一步只有 4 个选择,到第二层时我们已经有 20 个节点了。如果用户只能做 2 个选择故事就结束了,那并不是一个非常有趣的故事。

换句话说,我们将很快溢出模型的上下文窗口。为了解决这个问题,我们可以使用两阶段方法来生成故事,即先生成初始故事设定,然后并行生成选项/其他选择。

并行故事生成

生成大纲

首先,我们使用 gpt-4o 生成故事的大纲。这很重要,因为它为我们提供了起始设定、视觉风格和图像描述(用于横幅图像)。我们可以在后续使用这些信息,尽可能确保生成的图像保持一致。

from pydantic import BaseModel
from typing import List

class GeneratedStory(BaseModel):
    setting: str
    plot_summary: str
    choices: List[str]
    visual_style: str
    image_description: str

async def generate_story(
    client: instructor.AsyncInstructor,
    story_input: RestateStoryInput
):
    resp = await client.chat.completions.create(
        messages=[{
            "role": "user",
            "content": """
            Generate a story with:
            - Setting: {{ story_input.setting}}
            - Title: {{ story_input.title }}

            Rules:
            - Generate 2-4 initial choices that represent actions
            - Choices must move story forward
            - Include brief setting description
            - Generate a visual description for the story

            Required Elements:
            1. Plot Summary: A vivid description of the setting and plot
            2. Initial Choices: 2-4 distinct actions the user can take
            3. Visual Style: Description of art style, color palette
            4. Image Description: One-sentence scene description
            """
        }],
        model="gpt-4o",
        response_model=GeneratedStory,
        context={"story_input": story_input},
    )
    return resp

这将输出包含设定、情节摘要、选择、视觉风格和图像描述的故事。

# Example generated output
{
    "setting": "A neon-lit cyberpunk metropolis in 2150",
    "plot_summary": "In the sprawling city of Neo-Tokyo...",
    "choices": [
        "Investigate the mysterious signal in the abandoned district",
        "Meet your contact at the underground hacker hub",
        "Follow the corporate executive who seems suspicious"
    ],
    "visual_style": "Vibrant neon colors, detailed cyberpunk architecture",
    "image_description": "A towering cyberpunk cityscape at night with neon signs"
}

并行选项扩展

生成深度故事树的最大挑战之一是在故事分支增长时保持一致性。

以下是我们在并行生成和状态跟踪方面解决此问题的方法

graph TD
    %% Main nodes
    A[Find Door] --> B[Open Door]
    A --> C[Walk Away]

    B --> D[Read Book]
    B --> E[Leave Room]

    C --> F[Go Home]
    C --> G[Wait Outside]

    %% Styling for visual hierarchy
    classDef start fill:#ff9999,stroke:#333,stroke-width:2px
    classDef decision fill:#99ccff,stroke:#333,stroke-width:2px
    classDef outcome fill:#99ffff,stroke:#333,stroke-width:1px

    %% Apply styles
    class A start
    class B,C decision
    class D,E,F,G outcome

    %% Add tooltips for context
    click B "Door context" "Open Door Context"
    click C "Away context" "Walk Away Context"
    click D "Door and Book context" "Read Book Context"

关键在于,故事树中的每个路径都有其独特的独立状态。我们通过一个简单的累加器来实现这一点,它可以帮助我们跟踪先前的选择和故事上下文。

在此值得注意的是,模型也完全可以在任何时间点结束故事。

以下是我们的实现方式

async def rewrite_choice(
    client: instructor.AsyncInstructor,
    choice: str,
    story: GeneratedStory,
    prev_choices: list[dict],  # Accumulator for path state
    max_depth: int,
    sem: asyncio.Semaphore
) -> FinalStoryChoice:
    # Each choice knows its entire path history
    async with sem:
        rewritten_choice = await client.chat.completions.create(
            model="gpt-4o",
            response_model=RewrittenChoice,
            messages=[{
                "role": "user",
                "content": """
                Given this choice: {{ choice }}

                Story context:
                Setting: {{ story.setting }}
                Plot: {{ story.plot_summary }}

                Previous choices made in this path:
                {% for prev in prev_choices %}
                - {{ prev.choice_description }}
                  Result: {{ prev.choice_consequences }}
                {% endfor %}

                Generate the next story beat and 2-4 new choices.
                The story should end in {{ max_depth - len(prev_choices) }} more turns.
                """
            }],
            context={
                "choice": choice,
                "story": story,
                "prev_choices": prev_choices,
            }
        )

    # For terminal nodes (at max depth)
    if len(prev_choices) == max_depth - 1:
        return FinalStoryChoice(
            choice_description=rewritten_choice.choice_description,
            choice_consequences=rewritten_choice.choice_consequences,
            choices=[]  # Terminal node
        )

    # Recursively expand child choices
    child_choices = await asyncio.gather(*[
        rewrite_choice(
            client=client,
            choice=new_choice,
            story=story,
            prev_choices=prev_choices + [{
                "choice_description": rewritten_choice.choice_description,
                "choice_consequences": rewritten_choice.choice_consequences
            }],
            max_depth=max_depth,
            sem=sem
        )
        for new_choice in rewritten_choice.choices
    ])

    return FinalStoryChoice(
        choice_description=rewritten_choice.choice_description,
        choice_consequences=rewritten_choice.choice_consequences,
        choices=child_choices
    )

这种方法带来了几个关键优势

  1. 路径特定上下文:每个节点都保留了导致该节点的完整选择历史记录,确保了每个分支内的一致性
  2. 并行生成:由于每个分支都保持自身的状态,因此可以同时生成不同的分支
  3. 受控增长max_depth 参数防止了指数级扩展
  4. 速率限制:信号量控制并发 API 调用,同时实现最大程度的并行化

信号量不仅用于速率限制,它还能确保我们在可控的速度下处理选择,同时保持状态的一致性。

故事树中的每个路径都成为一个独立的叙事,可以访问其完整的历史记录,这使我们能够以比单次调用快得多速度和详细程度生成连贯的故事。

此外,我们可以生成比单次调用能够生成的更广泛、更深度的故事。

故事生成之外

这种方法的成功归结为三个关键原则

  1. 状态隔离:每个节点仅保留所需的上下文,防止上下文窗口溢出
  2. 并行处理:可以在不同分支上同时进行生成,从而显著减少总生成时间
  3. 结构化验证:使用 Pydantic 模型确保每个生成的组件都符合您的要求

例如,顺序生成一个 20 节点的故事树可能需要 60 秒(每个节点 3 秒),但使用并行生成和 10 个并发请求,可能只需 45-50 秒即可完成。

这种模式在以下情况下特别有价值

  • 您的生成任务自然形成树形或图形结构
  • 单个节点需要其祖先的部分而非全部上下文
  • 您需要生成超出单个上下文窗口的内容
  • 生成速度很重要

通过将结构化输出与并行生成相结合,您可以可靠地大规模生成复杂、相互关联的内容,同时保持一致性和控制力。

instructor 使使用语言模型生成复杂数据结构变得容易,无论它们是使用 ollama 的开源模型,还是使用 OpenAI 等提供商的专有模型。今天就来试试吧!