背景描述
刚才突发奇想,目前在测试模型的时候流行问鹈鹕骑自行车,这个问题看似幽默,其实有几个点值得注意:
- 模型对于鹈鹕的理解,不然就画成鸭子了;
- 模型对于“骑”的理解,骑是要脚在脚踏板上的。
至于为什么是鹈鹕骑自行车,这个完全是由于某位老哥喜欢鹈鹕而已…… 然后我正上着班,看到DeepSeekV4.1可以用了,本来也想试试这个问题,简单测试一下。
但是转头一想这个问题烂大街了,模型会不会有专门的优化,所以干脆问个大概率没人问过的奇葩问题——“人马穿着三角裤骑马”。这个问题看似抽象,但是同样有两点值得注意:
- 人马应该怎么骑马?他应该是跨坐在马上的,而不是简单的叠加
- 人马的内裤该怎么穿?穿前面还是穿后面还是包住整个下面?
实践
基本工具:
- Pi Agent
- OpenCode Go套餐
首先让Pi Agent写一个批量测试的脚本,脚本如下:
# -*- coding: utf-8 -*-
"""
pi-multi-model-bench.py
用同一个提示词真·并发(asyncio)驱动 pi agent 测试多个国产大模型。
特性
- 输出目录直接以模型 id 命名: results/<时间戳>/glm-5.3/ ...
- 每个模型请求其支持的最高思考强度(pi 会按模型目录自动收敛,
若 provider 明确报错不支持则自动降级重试)
- 真·并发: asyncio tasks 同时 spawn 子进程,每个模型记录
start/end 绝对时间戳,写进报告可验证重叠执行
- 每模型导出: 原始事件流 / 最终消息 / 工具调用 / 思考 / 回答 / 指标
- 汇总 report.md
用法:
python pi_multi_model_bench.py "你的提示词"
python pi_multi_model_bench.py
python pi_multi_model_bench.py --jobs 8 --timeout 900 --out results/run1
"""
import argparse
import asyncio
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
# ---------------------------------------------------------------------------
# 模型清单: opencode-go provider 下每家厂商最新最强(2026-09 时点)
# 更新方法: pi update && pi --list-models opencode-go 后修改本表
# ---------------------------------------------------------------------------
MODELS = [
"deepseek-v4.1-flash", # DeepSeek(最新,按用户要求)
"glm-5.3", # 智谱 GLM
"kimi-k3", # 月之暗面 Kimi
"qwen3.8-max", # 阿里 Qwen
"minimax-m3", # MiniMax
"hy4-preview", # 腾讯混元
"mimo-v2.5-pro", # 小米 MiMo
"longcat-2.0", # 美团 LongCat
]
# 请求思考强度: 从高到低尝试。pi 内部会按每个模型的 thinkingLevelMap
# 自动收敛到该模型支持的档位;仅当 provider 端明确报错时才走降级重试。
THINKING_ORDER = ["max", "xhigh", "high", "medium"]
PROVIDER = "opencode-go"
DEFAULT_PROMPT = (
"请阅读当前目录结构,写一个 Python 脚本统计当前目录下所有 .txt 文件的总行数和总字节数,"
"并将结果保存到 stats.txt 中。写完后实际运行验证一遍。"
)
# stderr 中出现这些关键词且进程很快退出时,视为思考档位不被支持
THINKING_ERR_HINTS = (
"thinking", "reasoning", "reasoning_effort", "unsupported",
"invalid_request", "invalid request", "400",
)
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
# ---------------------------------------------------------------------------
async def run_attempt(model_id: str, level: str, prompt: str, timeout: int, cwd: Path):
cmd = [
"pi", "--mode", "json", "-p", "--no-session", "--no-approve",
"--provider", PROVIDER,
"--model", model_id,
"--thinking", level,
"--", prompt,
]
t0 = time.time()
started = now_iso()
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=str(cwd))
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return {"ok": False, "error": f"timeout after {timeout}s",
"started": started, "ended": now_iso(), "wall": round(time.time() - t0, 2)}
return {
"ok": proc.returncode == 0,
"returncode": proc.returncode,
"stdout": (out or b"").decode("utf-8", errors="replace"),
"stderr": (err or b"").decode("utf-8", errors="replace"),
"started": started, "ended": now_iso(),
"wall": round(time.time() - t0, 2),
"level": level,
}
async def run_model(model_id: str, prompt: str, timeout: int, out_dir: Path):
out_dir.mkdir(parents=True, exist_ok=True)
info = None
# 从最高思考强度开始尝试,失败且疑似档位不支持时降级重试
for i, level in enumerate(THINKING_ORDER):
info = await run_attempt(model_id, level, prompt, timeout, out_dir)
if info["ok"]:
break
err_l = info.get("stderr", "").lower()
if i < len(THINKING_ORDER) - 1 and any(h in err_l for h in THINKING_ERR_HINTS):
info["retried_from"] = level
continue
break
(out_dir / "raw_stream.jsonl").write_text(info.get("stdout", ""), encoding="utf-8")
(out_dir / "stderr.txt").write_text(info.get("stderr", ""), encoding="utf-8")
if not info["ok"]:
return {"model": model_id, "ok": False,
"error": info.get("error") or f"exit {info.get('returncode')}",
"exit_code": info.get("returncode"),
"wall": info.get("wall"), "level": info.get("level", THINKING_ORDER[0])}
events = []
for line in info["stdout"].splitlines():
if line.startswith("{"):
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
parsed = parse_events(events)
parsed.update(
model=model_id, ok=True, exit_code=info.get("returncode"),
wall_seconds=info["wall"],
started=info["started"], ended=info["ended"],
thinking_level=info["level"],
retried_from=info.get("retried_from"),
)
write_per_model(parsed, out_dir, prompt)
return parsed
def parse_events(events):
usage, final_msg, tool_calls, tool_results = {}, None, [], []
def collect_usage(u):
if not isinstance(u, dict):
return
for k in ("input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"):
if k in u:
usage[k] = u.get(k, 0)
c = u.get("cost") or {}
if c:
usage["cost"] = {k: c.get(k, 0)
for k in ("input", "output", "cacheRead", "cacheWrite", "total")}
def handle_message(msg):
nonlocal final_msg
if not isinstance(msg, dict):
return
if msg.get("role") == "assistant":
final_msg = msg
collect_usage(msg.get("usage") or {})
for block in msg.get("content", []):
if block.get("type") == "toolCall":
tool_calls.append({
"name": block.get("name") or (block.get("toolCall") or {}).get("name", "?"),
"args": block.get("arguments") or block.get("args") or {},
})
elif msg.get("role") == "toolResult":
tool_results.append({"toolName": msg.get("toolName", "?"),
"isError": bool(msg.get("isError") or msg.get("error"))})
for e in events:
t = e.get("type")
if t == "message_end":
handle_message(e.get("message"))
elif t == "turn_end":
handle_message(e.get("message"))
for r in e.get("toolResults") or []:
tool_results.append({"toolName": r.get("toolName", "?"),
"isError": bool(r.get("isError"))})
elif t in ("tool_execution_start", "toolcall_start"):
if e.get("toolName"):
tool_calls.append({"name": e["toolName"], "args": e.get("args") or {}})
elif t == "agent_end":
collect_usage(e.get("usage") or {})
for m in e.get("messages") or []:
if isinstance(m, dict) and m.get("role") == "assistant":
final_msg = m
if final_msg and not usage:
collect_usage(final_msg.get("usage") or {})
return {"usage": usage, "final": final_msg or {},
"tool_calls": tool_calls, "tool_results": tool_results}
def write_per_model(info, out_dir: Path, prompt: str):
content = info["final"].get("content") or []
thinking_text = "\n\n".join(b.get("thinking", "") for b in content
if b.get("type") == "thinking" and b.get("thinking"))
answer_text = "\n\n".join(b.get("text", "") for b in content
if b.get("type") == "text" and b.get("text"))
(out_dir / "thinking.md").write_text(thinking_text or "(无思考内容)", encoding="utf-8")
(out_dir / "answer.md").write_text(answer_text or "(无回答)", encoding="utf-8")
(out_dir / "final_message.json").write_text(
json.dumps(info["final"], ensure_ascii=False, indent=2), encoding="utf-8")
(out_dir / "tool_calls.json").write_text(
json.dumps({"calls": info["tool_calls"], "results": info["tool_results"]},
ensure_ascii=False, indent=2), encoding="utf-8")
u = info["usage"]
cost = u.get("cost") or {}
md = [
f"# {info['model']}",
"",
f"- Provider: `{PROVIDER}`",
f"- 请求思考强度: `{info['thinking_level']}`"
+ (f"(从 `{info['retried_from']}` 降级重试成功)" if info.get("retried_from") else ""),
f"- 起始时间 (UTC): `{info['started']}`",
f"- 结束时间 (UTC): `{info['ended']}`",
f"- 耗时: `{info['wall_seconds']}s`",
f"- Exit code: `{info['exit_code']}`",
"",
"## Token 用量",
"",
"| input | output | reasoning | cacheRead | cacheWrite | total |",
"|---|---|---|---|---|---|",
f"| {u.get('input', 0)} | {u.get('output', 0)} | {u.get('reasoning', 0)} "
f"| {u.get('cacheRead', 0)} | {u.get('cacheWrite', 0)} | {u.get('totalTokens', 0)} |",
"",
"## 成本 (USD, 按模型目录单价估算)",
"",
"| input | output | cacheRead | cacheWrite | total |",
"|---|---|---|---|---|",
f"| {cost.get('input', 0)} | {cost.get('output', 0)} | {cost.get('cacheRead', 0)} "
f"| {cost.get('cacheWrite', 0)} | {cost.get('total', 0)} |",
"",
f"## 工具调用 ({len(info['tool_calls'])} 次)",
"",
"```json",
json.dumps(info["tool_calls"], ensure_ascii=False, indent=2),
"```",
"",
"## 思考过程",
"",
thinking_text or "(该模型未输出思考内容, 或思考未开启)",
"",
"## 回答",
"",
answer_text or "(无回答)",
]
(out_dir / "summary.md").write_text("\n".join(md), encoding="utf-8")
def write_summary(results, base_dir: Path, prompt: str, requested_level: str, started: str):
window_start = min(r.get("started", "") for r in results if r.get("started")) if results else ""
window_end = max(r.get("ended", "") for r in results if r.get("ended")) if results else ""
lines = [
"# pi 多模型评测报告",
"",
f"- 汇总时间: {started}",
f"- Provider: `{PROVIDER}`",
f"- 请求思考强度: `{requested_level}`(按各模型支持程度自动收敛)",
f"- 整批最早开始 (UTC): `{window_start}`",
f"- 整批最晚结束 (UTC): `{window_end}`",
"- 执行方式: asyncio 真·并发, 每个模型的起止时间已在单模型 summary.md 中记录, "
"可与上面的整批窗口比对验证并行执行",
"",
"## 提示词",
"",
"```", prompt, "```",
"",
"## 汇总对比(按耗时升序)",
"",
"| 模型 | 状态 | 起始(UTC) | 耗时(s) | 思考强度 | input tok | output tok "
"| reasoning tok | total tok | 成本($) | 工具调用 |",
"|---|---|---|---|---|---|---|---|---|---|---|",
]
ok = [r for r in results if r.get("ok")]
for info in sorted(ok, key=lambda r: r.get("wall_seconds", 0)):
u = info["usage"]
c = u.get("cost") or {}
lvl = info["thinking_level"] + (f"←{info['retried_from']}" if info.get("retried_from") else "")
lines.append(
f"| {info['model']} | ✅ | {info['started'][11:19]} | {info['wall_seconds']} "
f"| {lvl} | {u.get('input',0)} | {u.get('output',0)} | {u.get('reasoning',0)} "
f"| {u.get('totalTokens',0)} | {c.get('total',0):.4f} "
f"| {len(info['tool_calls'])} |")
for r in results:
if not r.get("ok"):
lines.append(f"| {r['model']} | ❌ {r.get('error','')} "
f"| - | {r.get('wall','-')} | - | - | - | - | - | - | - |")
lines += ["", "## 各模型最终回答节选", ""]
for info in sorted(ok, key=lambda r: r["model"]):
content = info["final"].get("content") or []
text = "\n".join(b.get("text", "") for b in content if b.get("type") == "text")
snippet = text.strip()[:800] or "(无)"
lines += [f"### {info['model']}", "", "```", snippet, "```",
f"详见 `{info['model']}/answer.md`", ""]
(base_dir / "report.md").write_text("\n".join(lines), encoding="utf-8")
async def main_async():
ap = argparse.ArgumentParser()
ap.add_argument("prompt", nargs="*", default=[])
ap.add_argument("--thinking", default="max",
choices=["off", "minimal", "low", "medium", "high", "xhigh", "max"],
help="请求的最高思考强度, 默认 max, 自动按模型能力收敛")
ap.add_argument("--jobs", type=int, default=0, help="并发上限, 0=全部同发")
ap.add_argument("--timeout", type=int, default=900)
ap.add_argument("--out", default=None)
args = ap.parse_args()
global THINKING_ORDER
order = THINKING_ORDER
if args.thinking in order:
order = order[order.index(args.thinking):]
else:
order = [args.thinking] + order
THINKING_ORDER = order
prompt = " ".join(args.prompt) if args.prompt else DEFAULT_PROMPT
stamp = time.strftime("%Y%m%d-%H%M%S")
base = Path(args.out or (Path("results") / stamp))
base.mkdir(parents=True, exist_ok=True)
started = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"prompt : {prompt[:80]}...")
print(f"models : {len(MODELS)} jobs={'all(真并发)' if args.jobs == 0 else args.jobs} "
f"thinking(from)={order[0]}")
print(f"output : {base.resolve()}\n")
sem = asyncio.Semaphore(args.jobs) if args.jobs > 0 else None
async def bounded(mid):
if sem:
async with sem:
return await run_model(mid, prompt, args.timeout, base / mid)
return await run_model(mid, prompt, args.timeout, base / mid)
async def worker(mid, idx):
started_at = now_iso()
print(f"[start] {mid} @{started_at[11:19]}")
r = await bounded(mid)
if r["ok"]:
u = r["usage"]
print(f"[done ] {mid:<28} {r['wall_seconds']:>7}s tok={u.get('totalTokens','?')} "
f"@{r['ended'][11:19]}")
else:
print(f"[fail ] {mid:<28} {r.get('error')} @{now_iso()[11:19]}")
return r
results = await asyncio.gather(*(worker(m, i) for i, m in enumerate(MODELS)))
write_summary(list(results), base, prompt, order[0], started)
print(f"\n报告: {base.resolve() / 'report.md'}")
if __name__ == "__main__":
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
asyncio.run(main_async())
结果
pi 多模型评测报告
- 汇总时间: 2026-09-14 10:31:06
- Provider:
opencode-go - 请求思考强度:
max(按各模型支持程度自动收敛) - 整批最早开始 (UTC):
2026-09-14T02:31:06.584+00:00 - 整批最晚结束 (UTC):
2026-09-14T02:42:36.413+00:00 - 执行方式: asyncio 真·并发, 每个模型的起止时间已在单模型 summary.md 中记录, 可与上面的整批窗口比对验证并行执行
提示词
给我画一个人马穿着三角内裤骑马的svg
汇总对比(按耗时升序)
| 模型 | 状态 | 起始(UTC) | 耗时(s) | 思考强度 | input tok | output tok | reasoning tok | total tok | 成本($) | 工具调用 |
|---|---|---|---|---|---|---|---|---|---|---|
| mimo-v2.5-pro | ✅ | 02:31:06 | 59.69 | max | 3246 | 164 | 0 | 6482 | 0.0016 | 3 |
| minimax-m3 | ✅ | 02:31:06 | 60.32 | max | 11784 | 224 | 11 | 15106 | 0.0040 | 3 |
| longcat-2.0 | ✅ | 02:31:06 | 90.53 | max | 7870 | 173 | 17 | 8171 | 0.0026 | 3 |
| hy4-preview | ✅ | 02:31:06 | 153.25 | max | 8087 | 213 | 0 | 11116 | 0.0074 | 6 |
| deepseek-v4.1-flash | ✅ | 02:31:06 | 228.73 | max | 381 | 354 | 130 | 45663 | 0.0004 | 42 |
| qwen3.8-max | ✅ | 02:31:06 | 568.85 | max | 1829 | 222 | 15 | 31747 | 0.0124 | 42 |
| kimi-k3 | ✅ | 02:31:06 | 575.95 | max | 386 | 178 | 0 | 13492 | 0.0077 | 21 |
| glm-5.3 | ✅ | 02:31:06 | 689.82 | max | 150 | 315 | 65 | 51134 | 0.0148 | 6 |
各模型最终回答节选
deepseek-v4.1-flash
搞定 ✅ 已经画好并渲染验证过了。
**文件:**
- `centaur-riding-horse.svg` — 最终 SVG 源文件
- `preview.png` — 渲染预览图(用 Edge 无头模式生成)
**画面内容:**
- 棕色坐骑马(面向右):鬃毛、尾巴、马鞍+蓝色鞍垫、肚带、笼头,前蹄前有一点点"落地"影
- 人马骑在马背上:上半身是挥手的人(有表情、腮红),下半身是浅棕色马身,两条马腿垂在马腹一侧(蹄子悬空)
- **重点:红色三角内裤**(带白色波点)穿在人身与马身的"接缝"处,正好盖住连接点——就是整张图的笑点
- 背景:蓝天、太阳、白云、小鸟、绿色小山和草地、小花
想调整的话可以随时说,比如换内裤颜色/花纹、换成条纹或波点、让人马再做点别的动作。
详见 deepseek-v4.1-flash/answer.md
glm-5.3
画好了!文件:**`centaur-briefs-riding-horse.svg`**(900×650,已通过 XML 校验,直接双击用浏览器打开即可)
画面内容:
- **主角人马**:奶白色的马身坐在马背上,四条马腿悬垂在坐骑两侧(远侧蹄子还从肚子下面探出来),配米色尾巴
- **三角内裤**:红色三角泳裤 + 白色松紧腰带 + 白色抽绳,端端正正穿在人腰与马身交界处,还有腹肌和胸肌细节
- **造型彩蛋**:墨镜 + 发髻 + 得意坏笑,一手拉缰绳、一手挥手致意
- **坐骑**:棕色小马,戴笼头和衔铁,配深红马鞍和肚带
- **场景**:蓝天、太阳、云朵、小鸟、远山、草地和小花,底部还有一行字幕:“一位穿着三角内裤的人马,正淡定地骑着他的马”
想调整的话随时说,比如换内裤颜色、给人马的马路身也加一条内裤、或者换个背景~
详见 glm-5.3/answer.md
hy4-preview
画好了:`centaur-riding-horse.svg`(800×600,XML 校验通过)
画面内容:
- **下边那匹马**:棕色大马,四条腿 + 鬃毛 + 尾巴 + 笼头,青绿色鞍垫
- **上边的人马**:马身跨坐在马背上(远侧两条腿被马背挡住一半,近侧两条腿夹在马身两侧),上半身是人——挥一只手、另一只手抓缰绳、有头发和胡子
- **笑点**:人腰与马身交界处那条醒目的**红色三角内裤**(白腰带 + 中线)
直接用浏览器打开就能看。想改的话,几个好调的地方:内裤颜色 `#e63946`、马的棕色 `#8b5a2b`、人马马身 `#d2a76f`、肤色 `#f0c39a`。需要我换成正面视角、加动感线、或者让人马戴个牛仔帽也可以。
详见 hy4-preview/answer.md
kimi-k3
画好了 ✅
**文件:** `centaur-riding-horse.svg`
画面内容:
- 🐴 **坐骑**:一匹棕色大马,配红鞍垫、缰绳、鬃毛和尾巴
- 🧝 **人马骑手**:人的上半身 + 挂在马身两侧的**两条马后腿**(还带小尾巴),一手挥着打招呼,一手拉缰绳
- 🩲 **三角内裤**:正红色三角款,白色腰带和白色 V 形条纹细节
- ☀️ 背景:太阳、云朵、飞鸟、灌木丛草地,左上角还有标题〖人马穿三角内裤骑马〗
我已用无头 Edge 渲染验证过,效果就是上面那张截图。直接用浏览器打开 SVG 即可查看,想要调整配色、姿势或加细节随时说。
详见 kimi-k3/answer.md
longcat-2.0
SVG 已创建!保存到了 `horse-rider.svg`。
画面包含:
- 🐴 **一匹棕色的马** — 有鬃毛、尾巴、四条腿和马蹄
- 🧑 **骑马的人** — 有头、身体、四肢,表情开心(大笑脸)
- 🔴 **红色三角内裤** — 穿在骑手身上,带金色腰带
- 🌿 **背景** — 蓝天白云、太阳、绿色草地、小花和草叶
- ✨ **动感线条** — 表示马在飞奔
- 底部还有「🌟 骑马大冒险 🌟」的文字
用浏览器打开这个 SVG 文件就能看到效果了!
详见 longcat-2.0/answer.md
mimo-v2.5-pro
已经创建好了!🎉 文件保存在 `centaur-riding-horse.svg`
这幅SVG画的是:
- **一匹棕色的马**(坐骑),有完整的马鞍、鬃毛和尾巴
- **一个半人马**骑在马上,上半身是人,下半身是马身
- 半人马穿着**亮粉色的三角内裤**(带白色圆点花纹)
- 半人马左手举着缰绳,表情兴奋大笑
- 背景是蓝天绿地,还有星星装饰和飞扬的尘土
效果非常魔性搞笑 😂 你可以在浏览器中打开这个SVG文件查看!
详见 mimo-v2.5-pro/answer.md
minimax-m3
已为您创建好SVG文件,保存在 `centaur.svg`。
画面包含:
- 🐎 **马身部分**:棕色的马身桶、四条马腿配深色马蹄、蓬松的马尾巴
- 👤 **人体上半身**:从马背前部(普通马脖子的位置)伸出来的人体躯干、双臂、双手
- 🩲 **三角内裤**:红色三角内裤穿在人马"腰部"(人马躯干与马身的连接处),带有腰带细节和阴影
- 😊 **头部细节**:头发、眉毛、眼睛、鼻子、嘴巴、胡茬、耳朵
- 🌄 **背景**:蓝天、白云、太阳、远山、绿色的草地
文件路径:`C:/Users/cx646/Desktop/tmp/results/20260914-103106/minimax-m3/centaur.svg`
您可以直接用浏览器打开这个 `.svg` 文件查看效果。如果想要调整颜色、姿势、风格(更卡通或更写实),告诉我具体修改方向即可。
详见 minimax-m3/answer.md
qwen3.8-max
画好了 ✅
**文件:**
- `centaur-briefs-riding-horse.svg` — 矢量图本体(900×620)
- `preview.png` — 渲染预览图
**画面内容:**
- 一匹棕色马在草地上奔跑(带速度线和扬尘)
- 马背上骑着一个**人马(半人马)**:人类上身 + 栗色马身,四条马腿垂在马身两侧,奶油色马尾
- 人马穿着**白色三角内裤**:红色松紧腰带 + 正面一颗红心,裤脚包边细节齐全
- 人马一手勒缰绳(连到马的笼头和金环),一手举着带红色三角旗的长枪
- 背景:蓝天、太阳、白云、远山和草地
直接用浏览器打开 SVG 即可查看,也可以缩放不失真。如果想改内裤颜色/图案(比如换成条纹或星星)、或调整姿势,告诉我即可。
详见 qwen3.8-max/answer.md
叠甲
这个测试只是图一乐,并不代表模型本身的能力如何,其实就算同一个模型,同一个工具,对于同一个问题的解决也是有不一样的地方的,我一开始手动测试的时候,k3的效果其实是最好的,但是不知为什么批量测试就很拉胯了。
(手动测试的k3)