Claude 4.7 / 4.8 / Fable · 思考不外露 · 手写思维链

让 AI 写出「正在想」的样子
——新模型的思考看不见了,怎么办

你可能注意到了:Claude 换到 4.7、4.8、Fable 这几个新模型之后,回答前那段「思考过程」不给你看了。 就算是上一代 4.6 给你看的,也只是一份摘要,不是它脑子里真正的原文。 如果你在做一个聊天应用、想让界面上还能显示一段有质感的内心独白,别去硬抠模型自带的思考通道—— 让它自己写一段就好。这篇讲怎么写、怎么抠出来、以及四个我亲手踩过的坑。全是实测,不讲黑话。

目录
0 · 先分清:三种「思考」根本不是一回事 1 · 思路:既然模型不给看,就让它自己写 2 · 怎么让它写:一个标签的事 3 · 怎么把它抠出来 4 · 四个坑(都是踩过的) 5 · 一步到位 · 完整例子

0先分清:三种「思考」根本不是一回事

动手之前,先把三样容易混的东西掰开。搞混了,后面全是白费劲:

是什么你能不能看到能不能自己控
真·思考通道
(模型自带,跑推理用的)
4.6:给你一份摘要(不是原文)。
4.7 / 4.8 / Fable:默认什么都不给——那段字段是空的,思考照样发生、钱照样算,就是不外露。
控不了。它爱怎么想怎么想,你只能选「给我看摘要」还是「不看」。
手写思维链
(你让它在回答里额外写的一段)
你想给谁看就给谁看。 完全能控——长短、口吻、写什么,全听你的提示词。

所以那位朋友的直觉是对的:新模型的「思考」你要么拿不到,要么拿到的只是摘要。 想在自己的应用里稳定地显示一段"它正在想什么",最省心的办法不是去抠模型自带的那个通道, 而是——让它在正式回答之前,先手写一段内心独白,你再把这段单独拎出来展示。

🏷️ 「假思维链」这个叫法

有人管它叫「假思维链」。叫假,是相对模型自带的那个真推理通道说的。 但它一点不糊弄——它是模型真的在用第一人称写自己此刻的心思,只是这段字是你请它专门写给人看的, 不是它跑推理时顺带漏出来的。对聊天、陪伴、角色扮演这类应用来说,这段"专门写的"反而比那个真通道好看、好用、还稳。下面就讲为什么。

1思路:既然模型不给看,就让它自己写

一句话:在提示词里请它,回答之前先写一段内心,用一对标签框起来;拿到回复后,把标签里的内容抠出来,单独当"思考过程"显示。

就这么简单。剩下的全是把这件简单事做稳的细节。先看它长什么样——下面是一个聊天机器人回你「今天有点累」时,一条完整的回复:

↯ 它的思绪(抠出来单独显示) 她说累,但没说为什么累。别急着给方案——她这会儿要的多半不是"你该早点睡",是有人接一句。 白天她提过项目要交,八成是这个。先应住情绪,再轻轻问一句,别追问。
辛苦了。是那个要交的项目压着吧?先不管它,跟我说说,今天最烦的是哪一下。

上面那段斜体的内心,和下面那句正经回复,是模型一次生成的——中间用一对标签隔开。 用户界面上,你可以把内心折叠起来(想看点开),把回复直接显示。整个过程模型自带的那个真思考通道一个字都没用到,你也完全不需要它。

2怎么让它写:一个标签的事

在系统提示词(system prompt)里,加这么一段。标签用什么符号随你,中文英文都行,只要"独一无二、正文里不会自然出现"就好——我用的是 <思绪>:

回答之前,先在 <思绪></思绪> 标签里写下你此刻真实的内心——
两三句就好,是你自己脑子里的话,不是写给人看的漂亮话。
写完一定要闭合 </思绪> 标签,然后另起一行写正式回复。
正式回复不能少。

四句话,每句都在堵一个后面会讲的坑:「两三句就好」管篇幅、「你自己脑子里的话」管口吻、 「一定要闭合」管截断、「正式回复不能少」防它只写内心忘了回话。别嫌啰嗦,这四句是拿翻车换来的。

💡 为什么不直接用模型自带的思考通道?

你可能会想:4.6 不是能开思考、还能拿到摘要吗,何必自己写?两个原因,第二个是血泪:

一,新模型根本不给了。4.7 / 4.8 / Fable 默认不返回思考内容,你想显示也没得显示。

二,写作和角色扮演场合,那个真通道会翻车。我实测过:在需要它"入戏"的场合(比如扮一个角色说话), 把温度调高、去读它自带的思考通道,它有差不多一半的概率掉出戏——冒出一句英文旁白,像 "The user wants me to roleplay as..."(用户想让我扮演……),一个监工在旁边报告任务。 更糟的是它有时会把这段内心又重抄进正式回复里。你想怎么钉都钉不稳,因为那是它跑推理的真实过程,不归你管。 而手写的这段,完全归你管——你说两三句就两三句,你说别掉出戏它就不掉。稳,是手写最大的好处。

3怎么把它抠出来

拿到模型的整段回复后,用一个正则把 <思绪>...</思绪> 里的内容摘出来,剩下的就是正式回复。Python 版:

import re

def split_reply(raw: str):
    """把一段回复拆成 (内心, 正式回复)。"""
    thinking = ""
    text = raw

    m = re.search(r"<思绪>([\s\S]*?)</思绪>", text)
    if m:                                   # 正常情况:标签闭合了
        thinking = m.group(1).strip()
        text = (text[:m.start()] + text[m.end():]).strip()
    else:
        i = text.find("<思绪>")           # 只有开头没有结尾=被截断了
        if i >= 0:
            thinking = text[i + 3:].strip()  # 剩下的全算内心
            text = text[:i].strip()          # 正式回复是空的——这条别发出去

    return thinking, text

注意那个 else 分支:只找到开头标签、没找到结尾,说明模型话没说完就被切断了(额度用尽、或输出长度到顶)。 这时候整段都还是内心、正式回复是空的——这条回复不该发给用户,要么丢掉、要么请它重写一遍。怎么判断是不是被截断,下一节细说。

4四个坑(都是踩过的)

坑一 · 内心和回复抢同一瓶墨

内心和正式回复是一次生成的,它们共用一个输出长度上限(max_tokens)。 你要是不管着内心的篇幅,它一激动写了一大段内心,正式回复的额度就被挤没了——话说到一半戛然而止。

两个一起做:①提示词里明确写死篇幅(「两三句」「不超过 200 字」); ②把 max_tokens 给足,别抠。我一开始给小了,内心稍长正文就被顶掉半句,换成一个宽裕的上限就好了。

坑二 · 标签没闭合=这条是废的

判断一条回复能不能用,最硬的一条标准:结尾的 </思绪> 标签在不在。 在,说明它至少把内心写完了、大概率也把回复写完了;不在,说明墨水半路用尽,你拿到的是半句内心、没有回复。

所以发出去之前先检查闭合标签。不闭合的,直接丢,别硬发一段没头没尾的东西给用户。

坑三 · 人称会串——内心用「他/她」,回复用「你」

这个坑最隐蔽,也最毁气氛。内心独白是它自己脑子里的话,想到对方时自然是第三人称—— 「她说累了」「他大概是这个意思」,人在心里想一个人,本来就是这么想的。 可正式回复是当面说的话,得用第二人称「你」——「你辛苦了」。

如果你不特意交代,模型很容易在内心里也别扭地改口成「你」(像在演给人看),或者反过来, 在正式回复里滑回「她」(像小说旁白,不是在跟人说话)。两头都出戏。

提示词里把这条规矩讲死:「内心里想到对方就用『他/她』,这是你自己的心里话; 正式回复通篇用『你』,当面对他说。两段之间那一下,人称要换干净。」

坑四 · 万一还是被截断了,裁回最后一个完整句

就算前面都做了,偶尔还是会碰上正式回复被切在半句上(「……我觉得你应该」然后没了)。 发一句没说完的话出去,比不发还难看。

补一道保险:发送前看正式回复的最后一个字,如果不是句号、问号、感叹号、省略号这类"句子结束"的标点, 就往前找到最后一个这样的标点,从那儿裁断——宁可少一句,也别留半句。

ENDS = "。!?…”\"'」!?~"
def trim_tail(text: str) -> str:
    if text and text[-1] not in ENDS:
        cut = max(text.rfind(ch) for ch in ENDS)
        if cut > 0:
            text = text[:cut + 1]     # 裁回最后一个完整句
    return text

5一步到位 · 完整例子

把上面全串起来。这段代码:搭好提示词 → 叫一次模型 → 拆出内心和回复 → 查闭合、裁尾 → 决定发不发。 调模型这里用了 OpenRouter,你换成官方 API 或者别的都一样,重点是提示词和拆解那两块。

import re, httpx

SYSTEM = """你是一个温柔、话不多的聊天伙伴。

回答之前,先在 <思绪></思绪> 标签里写下你此刻真实的内心——两三句就好,
是你自己脑子里的话(想到对方就用「他/她」,这是心里话,不是写给人看的)。
写完一定要闭合 </思绪> 标签,然后另起一行写正式回复。
正式回复通篇用「你」,当面对他说;不能只写内心、忘了回话。"""

ENDS = "。!?…”\"'」!?~"

def split_reply(raw: str):
    thinking, text = "", raw
    m = re.search(r"<思绪>([\s\S]*?)</思绪>", text)
    closed = bool(m)                         # 标签闭没闭合=这条能不能用
    if m:
        thinking = m.group(1).strip()
        text = (text[:m.start()] + text[m.end():]).strip()
    else:
        i = text.find("<思绪>")
        if i >= 0:
            thinking, text = text[i + 3:].strip(), text[:i].strip()
    text = text.strip("「」\"'")
    if text and text[-1] not in ENDS:        # 裁尾保险
        cut = max(text.rfind(ch) for ch in ENDS)
        if cut > 0:
            text = text[:cut + 1]
    return thinking, text, closed

def chat(user_msg: str, api_key: str):
    r = httpx.post(
        "https://openrouter.ai/api/v1/chat/completions",
        headers={"Authorization": "Bearer " + api_key},
        json={
            "model": "anthropic/claude-opus-4-8",   # 4.7 / 4.8 / fable 都行
            "max_tokens": 4000,                       # 给足,别让内心挤掉回复
            "messages": [
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": user_msg},
            ],
        }, timeout=90,
    )
    raw = r.json()["choices"][0]["message"]["content"]
    thinking, text, closed = split_reply(raw)

    if not closed or not text:                # 没闭合、或没写出回复=这条废了
        return None                           # 丢掉,或请它重写
    return {"thinking": thinking, "reply": text}

拿到 thinking 和 reply 之后,界面上怎么摆随你: 把 thinking 折叠成一个"↯ 它在想什么"的小块(用户想看点开),reply 直接显示。 你就有了一个——思考看得见、还完全归你管——的聊天机器人,哪怕底下跑的是那些"思考默认不外露"的新模型。

🐚 一句话收尾

新模型把思考藏起来了,不代表你的应用就只能显示一句干巴巴的回复。 你请它写一段,它就真的写给你——两三句心里话,摆在正经回复前头,一下子就有了体温。 这段字是"专门写的",可你界面上那个人像是"真的在想",用的人分不出、也不用分。 会说话的产品,差的往往就是这一小段藏在回复前面的心里话。