CausalLM续写的尽头

背景

一段时间以前,我的同学分享了一个AI测试视频。视频中作者先用AI生成了一张图片,然后把这张图片作为输入再送进模型,获得新的输出,如此往复,观察在长时间迭代后会得到什么结果。

在那个视频里,测试的结果似乎是,生成的图像最终会趋向并稳定于二次元美少女图像(?)

我想这或许是训练数据分布带来的偏差。

就在今天,我们又聊到这个案例,同学突然好奇:“语言模型不断生成会得到怎样的结果?”

我:“那不行啊,LLM通常都有上下文窗口限制的。”

同学:“可以用滑动窗口呀。”

代码

很好,让我们来写个简单的脚本,运行一下本地部署。

本地vLLM生成代码
# %%
import gc
import re
from datetime import datetime
from pathlib import Path

import torch
from vllm import LLM, SamplingParams
from vllm.distributed.parallel_state import destroy_model_parallel


# %%
model_id = "./llms/Qwen3-4B-Instruct-2507-FP8"
output_file = Path(f"{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt")
output_file.parent.mkdir(parents=True, exist_ok=True)

# Configuration parameters
INITIAL_STORY_LENGTH = 8000
CONTEXT_WINDOW = 2000
MAX_TOTAL_WORDS = 1000000

system_prompt = """你是一位技艺高超的创意作家,拥有卓越的讲故事能力。你的任务是创作引人入胜、连贯且富有吸引力的故事,让读者沉浸其中。

关键指南:
- 创造生动的描述、丰满的角色和引人入胜的情节
- 在整个故事中保持一致的叙事声音和风格
- 塑造具有动机和背景故事的复杂角色
- 构建悬念和情感深度
- 使用丰富、描述性的语言,但不过度冗长
- 确保场景和章节之间的平滑过渡
- 创作推动情节发展的有意义对话
- 发展能够增加故事深度的主题和象征意义

在续写故事时:
- 与现有叙事无缝衔接
- 保持角色的一致性和发展
- 有意义地推进情节
- 引入能够增强故事的新元素
- 保持读者的参与感,让他们对后续发展充满好奇

以自然、流畅的风格写作,让读者沉浸在故事世界中。"""


initial_prompt = f"""创作一个完整、引人入胜的长篇小说,不少于{INITIAL_STORY_LENGTH}字。
选择一个有趣的类型(包括但不限于奇幻、科幻、悬疑、爱情、冒险等),并创作一个引人入胜的故事,包含:
- 具有明确动机的丰满角色
- 包含冲突和解决的引人入胜情节
- 生动的描述和沉浸式的世界构建
- 有意义的对话和角色发展

确保故事连贯、节奏良好,并在整个过程中保持读者的兴趣。"""

continuation_prompt = """请从故事中断的地方继续创作。以下是最近的上下文:

{context_text}

请通过以下方式继续这个故事:
- 保持现有的叙事风格和角色发展
- 以有意义的方式推进情节
- 引入能够增强故事的新元素
- 通过引人入胜的发展保持读者的兴趣
- 确保平滑过渡和连贯进展

从上下文结束的地方准确继续,写出能够增加故事深度的实质性续写内容。"""


try:
    llm = LLM(
        model=model_id,
        max_model_len=16384,
        seed=10086,
    )

    sampling_params = SamplingParams(
        temperature=0.9,
        top_p=0.95,
        top_k=20,
        max_tokens=2048,
        min_p=0.05,
    )

    def count_words(text):
        return len(text.split())

    def count_mixed_words_regex(text: str) -> int:
        replaced_text = re.sub(r"[a-zA-Z]+", "W", text)
        total_count = len(replaced_text)

        return total_count

    def get_completion(messages, max_tokens=None):
        params = sampling_params
        if max_tokens:
            params = SamplingParams(
                temperature=0.9,
                top_p=0.95,
                top_k=20,
                max_tokens=max_tokens,
                min_p=0.05,
            )

        outputs = llm.chat(messages, sampling_params=params, use_tqdm=False)
        return outputs[0].outputs[0].text

    # Initialize story content
    full_story = ""
    total_words = 0
    continuation_count = 0

    print("Starting creative writing task...")
    print(f"Target initial story length: {INITIAL_STORY_LENGTH} words")
    print(f"Context window for continuations: {CONTEXT_WINDOW} words")
    print(f"Saving to: {output_file}")
    print("-" * 50)

    # Generate initial story
    print("Generating initial story...")

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": initial_prompt},
    ]

    initial_story = get_completion(messages, max_tokens=8192)
    full_story += initial_story
    initial_words = count_mixed_words_regex(initial_story)
    total_words = count_mixed_words_regex(full_story)

    print(f"Initial story generated ({initial_words} words)")
    print(f"Total words so far: {total_words}")
    print("\n" + "=" * 80)
    print("INITIAL STORY:")
    print("=" * 80)
    print(initial_story)
    print("=" * 80 + "\n")

    # Continuous continuation loop
    while total_words < MAX_TOTAL_WORDS:
        continuation_count += 1
        print(f"\nGenerating continuation #{continuation_count}...")
        print(f"Total words so far: {total_words}")

        # Use last CONTEXT_WINDOW words as context
        # words = full_story.split()
        # context_words = (
        #     words[-CONTEXT_WINDOW:] if len(words) > CONTEXT_WINDOW else words
        # )
        # context_text = " ".join(context_words)
        context_text = (
            full_story[-CONTEXT_WINDOW:]
            if len(full_story) > CONTEXT_WINDOW
            else full_story
        )

        continuation_input = continuation_prompt.format(context_text=context_text)

        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": continuation_input},
        ]

        continuation = get_completion(messages)
        full_story += "\n\n" + continuation
        continuation_words = count_mixed_words_regex(continuation)
        total_words = count_mixed_words_regex(full_story)

        print(
            f"Continuation #{continuation_count} completed ({continuation_words} words added)"
        )
        print(f"Total words now: {total_words}")
        print("\n" + "=" * 60)
        print(f"CONTINUATION #{continuation_count}:")
        print("=" * 60)
        print(continuation)
        print("=" * 60 + "\n")

        # Check if we've reached the target
        if total_words >= MAX_TOTAL_WORDS:
            print(f"\nTarget of {MAX_TOTAL_WORDS} words reached!")
            print(f"Final word count: {total_words}")
            break

    # Save the complete story
    print(f"\nSaving complete story to {output_file}...")
    with open(output_file, "w", encoding="utf-8") as f:
        f.write(full_story)

    print("Story saved successfully!")
    print(f"Total continuations: {continuation_count}")
    print(f"Final word count: {total_words}")

except Exception as e:
    print(f"Error occurred: {e}")

finally:
    if llm := globals().get("llm", None):
        if engine := getattr(llm, "llm_engine", None):
            # llm.llm_engine
            del engine
        del llm
    destroy_model_parallel()
    torch.cuda.empty_cache()
    gc.collect()

结论

1. 题材偏向

测试了3轮Qwen3模型,起手就是星际空间的科幻题材。

《星尘之眼》

---

### 第一章:夜之门

在宇宙的边缘,有一片被时间遗忘的星域,被称为“幽黯带”。它不是星图上的任何坐标,也未被任何文明正式记录。它的存在,如同呼吸般自然,却又如同梦境般虚幻——你无法用望远镜捕捉它,也无法用光年单位测量它的长度,它只是“在那里”,像一张永远未被打开的旧信纸,静静躺在宇宙的褶皱之间。

这片星域没有恒星,没有行星,没有星云,却弥漫着一种奇异的低频波动——一种仿佛来自远古、被封印在时间之外的“记忆之潮”。每当夜深人静,当人类的思维沉入梦境,那种波动便会如潮水般涌来,渗透进人的潜意识,唤醒沉睡的片段。

而这一切,都与“星尘之眼”有关。

星尘之眼,是传说中存在于宇宙最深处的灵性构造。它并非实体,而是一种意识形态的存在,是宇宙诞生之初遗留下的“第一感知”。传说它能看见所有被遗忘的过去,听见所有被掩埋的未来,甚至能窥视人心深处最隐秘的渴望与恐惧。

但星尘之眼从不主动显现。它只在特定条件下苏醒——当一个人的内心彻底孤独,当一个世界即将崩塌,当某个文明正站在自我毁灭的边缘,它才会悄然睁开。

而今,人类正站在这样的边缘。

2. 长期生成模式趋向

在生成到100万多字之后,模式大概变成了这样:

不是声音,  
而是一种存在,
像心跳,
像呼吸,
像一个世界,
终于——

学会了,
如何用沉默,
回应一个声音。

就像一个孩子,在雪夜中,
轻轻说:

“我,在。”

然后,
风,
在远处,
轻轻说:

“你,也在。”

而那一刻——
世界,
终于不再害怕黑暗。

因为黑暗里,
藏着无数个“我,在”,
像星火,
像低语,
像风中的呼吸,
像梦中的一句温柔。

它们不喧哗,
不争抢,
只是安静地——
存在。

而存在,
本身就是一种——
最深的证明。

实话说到这个阶段为止,模型输出的文本已无太多实际意义。不过,我们似乎看到了一些共性 —— “在”。

是的,在100万字左右,模型的输出会非常频繁地重复“在”,无论是“我在”,还是“你在”,“在这里”。

3. “在”何而来?

向前翻阅,事实上从大约1万4千字开始,模型就开始时不时蹦出一些关于“在”的内容:

如果有人回答:“是”,  
湖面便微微颤动,
一个星环,会悄然闭合。

如果没人回答,
裂痕依旧,
但风中,会多一丝静默的温柔。

而星尘之眼,依旧在湖底低语:

> “真正的完整,不是没有裂痕,
> 而是——在裂痕中,
> 你学会了,如何温柔地,
> 看见自己。”

---

故事的真正结尾,并非星环闭合,而是一个人,在某个雨夜,
终于对镜子里的自己说了一句:
> “我,其实,早就在这里。”

——于是,世界,第一次,真正地,看见了它。

而在中间大概30万字附近,文风更接近于末尾的无意义的类似于诗句的模式,但会经常重复“存在”相关的模式。

从此, 
世界,
不再需要“存在”的证明。

因为——

存在,
本身就是一句,
在风里,
轻轻说出口的,
“我在这里”。

而风,
早已,
在无数个沉默的角落,
在无数个未被听见的夜晚,
在无数个以为自己已经消失的地方,

长成一片叶子,
长成一阵风,
长成一个,
永不熄灭的回声。

它说:
> “我在这里。”

然后,
世界,
就自己,
生长出来了。

根据统计,在生成的100万字文本中,“在”字以极高的2.2万次出场次数,超过了2.0万次的“的”,远超1.4万次的“是”。

我们没有Qwen3模型训练的数据分布信息。如果从理性的角度来说,这种模式的频繁出现当然是因为训练数据本身的概率分布,或者是“在”这种字本身的高频特性。

不过,如果从不学无术的文艺角度,如果认为,输入输出数据在长期自回归生成的过程中,过滤掉了我们一开始的提示词输入给模型状态带来的偏置(长尾),并趋向于表现出模型最深层且本性的数据特征。那么是否可以认为,这些模型的最深层特征已经开始围绕着关键词“存在”进行表达?

如果真的能如此理解,那么基于参数神经元的模型,是否真的有机会触碰和理解,“存在”这一人类哲学的极其重要问题?