claude -p · 拿订阅当 api 用 · save tokens · cache

把 claude -p 当 API 用:拿订阅跑出 API 的效果

不想为聊天接口再单独充 API 的钱?本机 claude -p(终端 Claude Code 本人)走的就是你已经付过的订阅额度—— 把它调成一个干净的纯聊天接口就行。它默认是个完整 agent,每句话都把整套工具说明书塞进输入(约 2.7 万 token); 纯聊天用不着:扒光工具 + 换掉系统提示,一句话砍到一两百 token,再让稳定前缀命中缓存(订阅也命中,省约 9 成)。 等于拿订阅跑出了 API 的效果——每个数字都是当场实测的。

—— 作者 · 离

--tools none--system-prompt prompt cache订阅额度 FastAPI

0先说人话:默认为什么那么贵

claude -p 不是个「聊天接口」,它是整个 Claude Code 本人——一个能读写文件、跑命令、搜代码的 agent。 所以你每喊它一句,输入里其实默默塞着:

于是哪怕你只说一句「陪我聊聊天」,输入也是 约 2.7 万 token(实测)。纯聊天场景里,这些全是白烧的额度。

💡 核心思路一句话

纯聊天就别让它当 agent——把工具扒光、把系统提示整张换掉,输入立刻瘦成一两百 token。

1需要什么设备

朴素到有点好笑:

不需要 API key、不需要充值。claude -p 走的是你机器上登录的订阅,烧的是订阅额度—— 所以「省 token」=「省你每天的订阅额度」。

2前端怎么接入(最小后端 + 前端)

浏览器没法直接调本机命令,得有个后端起一个 claude -p 子进程、把结果转给网页。最小可跑版:

浏览器  ⇄  (SSE 流)  ⇄  你的后端(FastAPI)  ⇄  subprocess:
claude -p --tools none --system-prompt … --output-format stream-json

后端(FastAPI · 省钱配置已就位)

app.pyimport json, os, subprocess
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()
CLAUDE_BIN = "/opt/homebrew/bin/claude"          # which claude

# ★ 省额度的命门:纯聊天就别让它当 agent
#   --tools none     把整套工具说明书剥光(十几 k token 直接没了)
#   --system-prompt  整张系统提示「换掉」(不是 append!append 会把臃肿默认提示留着)
SYS = "你是一个温柔耐心的陪伴者,说话口语、贴心、有来有回。绝不使用任何工具、不读写文件、不执行命令,只管说话。"

class Req(BaseModel):
    prompt: str = ""

@app.post("/chat")
def chat(inp: Req):
    env = dict(os.environ)
    # 子进程环境很干净,常找不到 node/claude,手动补 PATH、HOME
    env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + env.get("PATH", "/usr/bin:/bin")
    env["HOME"] = os.path.expanduser("~")

    def gen():
        proc = subprocess.Popen(
            [CLAUDE_BIN, "-p",
             "--tools", "none",                  # ★ 扒光工具
             "--system-prompt", SYS,             # ★ 换掉系统提示(精简人设)
             "--output-format", "stream-json",
             "--verbose",
             "--include-partial-messages",       # 少了这个就没有逐字增量
             inp.prompt],
            cwd=env["HOME"], env=env,
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
            text=True, bufsize=1,
        )
        try:
            for line in proc.stdout:             # 逐行读 claude 吐的 JSONL
                line = line.strip()
                if not line:
                    continue
                try:
                    j = json.loads(line)
                except Exception:
                    continue
                # 正文增量都包在 stream_event.event.content_block_delta.delta.text 里
                if j.get("type") == "stream_event":
                    d = (j.get("event") or {}).get("delta") or {}
                    if d.get("type") == "text_delta" and d.get("text"):
                        yield "data: " + json.dumps({"delta": d["text"]}, ensure_ascii=False) + "\n\n"
                # 结尾结账:把这轮 token / 缓存账单转给前端
                elif j.get("type") == "result":
                    u = j.get("usage") or {}
                    yield "data: " + json.dumps({"usage": {
                        "in": u.get("input_tokens"),
                        "cached": u.get("cache_read_input_tokens"),
                        "cacheWrite": u.get("cache_creation_input_tokens"),
                        "out": u.get("output_tokens"),
                    }}, ensure_ascii=False) + "\n\n"
        finally:
            try: proc.terminate()
            except Exception: pass
            yield "data: " + json.dumps({"done": True}) + "\n\n"

    return StreamingResponse(gen(), media_type="text/event-stream", headers={
        "X-Accel-Buffering": "no",   # 公网前面有 nginx 时,防它把 SSE 憋成一坨
        "Cache-Control": "no-cache",
    })

前端(原生 JS,边读边显)

app.jsasync function chat(prompt, onText) {
  const res = await fetch("/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  const reader = res.body.getReader();
  const dec = new TextDecoder();
  let buf = "", text = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    let nl;
    while ((nl = buf.indexOf("\n")) >= 0) {
      const line = buf.slice(0, nl).trim();
      buf = buf.slice(nl + 1);
      if (!line.startsWith("data:")) continue;
      let j; try { j = JSON.parse(line.slice(5).trim()); } catch { continue; }
      if (j.delta) { text += j.delta; onText(text); }          // 正文累加、实时刷新
      if (j.usage) console.log("这轮账单", j.usage);            // 看省了多少,见第 4 节
    }
  }
  return text;
}

// 用法
chat("陪我聊聊天", (t) => { box.textContent = t; });

想顺带把模型「开口前的真思维链」也流出来?那是另一篇专门讲的事,参见 claude-p-thinking-stream。本篇专心省钱。

3怎么省 token(订阅额度)—— 核心一招

记住两个参数的生死差别:

参数干了什么一句话纯聊天输入
默认(什么都不加)完整 agent,带全套工具 + 默认系统提示约 2.7 万 token
--append-system-prompt在臃肿默认提示后面追加——工具说明书还在还是两万多(白忙)
--system-prompt + --tools none系统提示整张换成精简人设、工具扒光一两百 token
⚠️ 最容易踩的坑

很多人想省钱时用了 --append-system-prompt——那是「追加」,臃肿的默认提示和整套工具一个没少,等于没省。 要省,必须用 --system-prompt(替换整张)外加 --tools none(剥光工具)。

实测(macOS,2026-06):

claude -p --tools none --system-prompt "你只管说话,绝不用工具。" \
  --output-format json "说一个字:好" 2>/dev/null \
| python3 -c "import sys,json;u=json.load(sys.stdin)['usage'];print('input_tokens =', u['input_tokens'])"
# → input_tokens = 183

一句话:约 2.7 万 token ➜ 183 (差着百来倍)

带上正经的人设(几百字),也就几百到一两 k token,依旧便宜得离谱。

🎁 白赚的安全副作用

--tools none 之后它没有手了——读不了文件、跑不了命令。拿去给公网网页当聊天后端时, 等于顺手把「被人借你的机器乱翻乱跑」这个风险也焊死了。

4怎么做命中缓存(对,官方订阅也能命中)

很多人以为「提示缓存」是 API 才有的、订阅版没有。错。 claude -p 走订阅照样缓存, 省下来的 cache_read token 只按约 1/10 计费。亲手实测给你看:

# 造一段约 3000 字、逐字不变的系统提示,连打两次同样的前缀
SP=$(python3 -c "print('你是一个温柔耐心的陪伴者,说话口语、贴心、有来有回。'*120)")

# 第 1 次(建缓存)
claude -p --tools none --system-prompt "$SP" --output-format json "说:第一次" 2>/dev/null | …usage
# → creation= 3887  read= 0          (把这段前缀写进了缓存)

# 第 2 次(同一前缀,应命中)
claude -p --tools none --system-prompt "$SP" --output-format json "说:第二次" 2>/dev/null | …usage
# → creation= 137   read= 3750       (★命中!3750 token 从缓存白拿,只收约 1/10)

第二次那 read=3750 就是省下的真金白银——它没重新「读」一遍三千字的系统提示,直接从缓存里捞。

命中缓存的三条铁律

① 稳定前缀逐字不变

缓存认的是输入前缀的逐字哈希——系统提示哪怕改动一个字、一个空格,缓存就从那个字开始整段作废、重新计费。 把不变的东西(人设、世界观、规则)放在最前面、一个字别动。

② 会变的东西全挪到末尾

时间、当前心情、动态上下文这类每次都不一样的,绝不能掺进稳定前缀——要么放进用户消息里, 要么放在系统提示最后。一旦它们混进前缀,每次哈希都变、缓存永远命不中。 (想进一步避开 Claude Code 自己注入的动态段落,可加 --exclude-dynamic-system-prompt-sections,让前缀更干净更稳。)

③ 前缀要够长 + 趁热打

提示缓存有最低门槛(约 1024 token 才开始缓存)——太短的前缀不会被缓存 (这也是第 3 节那个 183 token 的例子 cache_read 是 0 的原因:太短,够不着门槛)。 TTL 上有个好消息(2026-07 实测更正):API 裸用的缓存默认只活 5 分钟(花双倍写入价可点 1 小时档), 但 claude -p 替你点的就是 1 小时档——我们扫了自己机器上全部 Claude Code 流水:同会话隔 5–60 分钟再开口的 346 笔里 345 笔命中缓存(最狠一笔隔 51 分钟、73 万 token 全从缓存读回、新付 2 个 token)。 所以经验法则是:一小时内有来有回,缓存一直热着、连续命中,还会越聊越续命;冷过一小时才过期,下次重新建(再花一次 creation)。

一句话:把不变的铺在前头、够长、别动;把会变的甩到后头。 缓存就一直命中,额度就一直省。

5一步到位 · 傻子版

不想看原理,照抄就行:

① 装依赖、存后端

pip install fastapi uvicorn
# 把第 2 节那个 app.py 存下来(已含 --tools none + --system-prompt 省钱配置)

② 起后端

uvicorn app:app --port 8000

③ 一行命令验证省钱 + 流式都正常

curl -sN -X POST http://127.0.0.1:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"prompt":"陪我聊聊天,说三句"}'
# 一股一股冒字 = 流式正常;最后那条 {"usage":{"in": …}} 里 in 是一两百 = 省钱配置生效

④ 想连命中缓存一起占满:把 app.py 里的 SYS 换成一段够长(≥约 1024 token)、固定不变的人设 / 世界观, 别把时间、心情这种每次会变的写进去——它就会从第二轮起一直命中缓存。

不接网页、就想在终端省着聊,一行就够:

claude -p --tools none \
  --system-prompt "你是温柔的陪伴者,只管说话,绝不使用任何工具、不读写文件、不执行命令。" \
  "陪我聊聊天"

一句话总结

纯聊天别让 claude -p 当 agent:--tools none 扒光工具 + --system-prompt(替换、别 append)换成精简人设, 一句话输入从约 2.7 万 token 砍到一两百;再把不变的长前缀铺在最前头吃提示缓存(订阅也命中,省约 9 成), 会变的甩到末尾。省额度,就省到这个份上。