本文档基于 run_prompt/run_prompt.py 的设计抽出来的可迁移模式,
供后续在 examples/process_pipeline/scratch/ 下新增工具脚本时作为参考。
不是教条 —— 每条规则都附了"为什么",遇到不适用的场景请基于场景判断。
每个独立工具一个子目录,脚本、数据、输出三者物理隔离:
examples/process_pipeline/scratch/
├── SCRIPT_TEMPLATE.md ← 本文档
└── <tool_name>/ ← 一个工具一个目录
├── <tool_name>.py ← 脚本本体(与目录同名)
├── inputs/ ← (前端预留)通过前端 UI 上传的数据文件默认存放在此
│ ├── <input_data>.json
│ └── prompt_input.txt
└── outputs/ ← 所有输出都在这里
├── result_<mode>_<ts>.txt
└── ...
为什么:
inputs/ 和 outputs/ 沙盒 → 整体 .gitignore 一行搞定,git status 不被结果污染,且前端面板会自动探测这两个文件夹用于“文件复用”。python <dir>/<dir>.py 路径直观"""
<一句话说明工具用途>
用法:
python <path>/<tool>.py --arg1 ... --arg2 ...
脚本通过探测 .git/pyproject.toml 自动定位项目根,可以放在仓库内任意位置。
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# Windows 控制台 UTF-8(中文输出/输入必备)
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
def _find_project_root(start: Path) -> Path:
"""沿父目录上爬,找到含 .git / pyproject.toml 的目录。"""
p = start.resolve()
for ancestor in [p, *p.parents]:
if (ancestor / ".git").exists() or (ancestor / "pyproject.toml").exists():
return ancestor
return start.resolve().parent
PROJECT_ROOT = _find_project_root(Path(__file__))
SCRIPT_DIR = Path(__file__).resolve().parent
OUTPUTS_DIR = SCRIPT_DIR / "outputs"
sys.path.insert(0, str(PROJECT_ROOT))
from dotenv import load_dotenv
load_dotenv(PROJECT_ROOT / ".env")
为什么这些选择:
import agent.* 和加载 .envprint 会 UnicodeEncodeErrorSCRIPT_DIR / OUTPUTS_DIR 模块级常量 → 后续函数直接引用,避免每处重新计算outputs/ 内def default_output_path(tag: str) -> Path:
"""带时间戳的默认输出文件,永不覆盖历史。"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return OUTPUTS_DIR / f"result_{tag}_{ts}.txt"
def resolve_user_output(rel_path: str) -> Path:
"""
把用户的 --output 解析到 OUTPUTS_DIR 下。
- 必须是相对路径
- 解析后必须落在 OUTPUTS_DIR 之内(防 `..` 越界)
"""
p = Path(rel_path)
if p.is_absolute():
raise SystemExit(f"ERROR: --output 必须是相对路径: {rel_path!r}")
target = (OUTPUTS_DIR / p).resolve()
try:
target.relative_to(OUTPUTS_DIR.resolve())
except ValueError:
raise SystemExit(f"ERROR: --output 越界到 {target}(不允许 '..')")
return target
def write_output(content: str, args, tag: str) -> None:
"""统一输出处理:默认写文件,--stdout 才打 stdout。"""
if args.stdout:
print(content)
return
out_path = resolve_user_output(args.output) if args.output else default_output_path(tag)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content, encoding="utf-8")
print(f"[info] written to {out_path} ({len(content)} chars)", file=sys.stderr)
为什么:
--output C:\Windows\system32\evil.txt;也防 --output ../../../etc/passwdrun() 开头就 resolve_user_output() 一次,避免长任务跑完才报路径错def read_prompt(prompt_arg: Optional[str], prompt_file: Optional[str]) -> str:
"""从 --prompt / --prompt-file / stdin 三选一。"""
if prompt_file:
return Path(prompt_file).read_text(encoding="utf-8").strip()
if prompt_arg == "-":
return sys.stdin.read().strip()
if prompt_arg:
return prompt_arg
raise SystemExit("ERROR: 请提供 --prompt TEXT / --prompt - / --prompt-file PATH")
为什么:
Get-Content prompt.txt | python ...)def read_file_for_prompt(path: str) -> str:
"""读单个文件。不做客户端预检 —— 信任下游报错。"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {path}")
return p.read_text(encoding="utf-8", errors="replace")
为什么:
errors="replace" → 二进制污染的文本不会让脚本崩,只是会有少量乱码run_prompt.py 早期教训)约定:
| 流 | 用途 | 例 |
|---|---|---|
| stderr | 所有进度日志、警告、错误 | [info] mode=... [error] ... !!! WARNING ... |
| stdout | 只用于最终结果(仅当 --stdout) |
LLM 答案 |
--output 文件 |
最终结果常规去向 | outputs/result_*.txt |
print(f"[info] starting...", file=sys.stderr) # 进度
print(content) # 仅当 --stdout 时
out_path.write_text(content, encoding="utf-8") # 默认
为什么:管道兼容性 —— python tool.py --stdout | jq ... 不会被进度日志污染。
"""
Return codes:
0 — success
1 — runtime error (LLM 调用失败、文件读不到、参数错…)
2 — partial success (输出被截断、超出 max_tokens 等)
3 — input validation error (沙盒越界、文件过大等)
130 — KeyboardInterrupt (Ctrl+C)
"""
为什么:让自动化脚本能根据退码做不同处理(if exit==2: 重试加大 max_tokens)。
"成功 0 / 失败 1" 是底线,但不要止步于此 —— 多 1-2 个退码能让 shell 脚本/CI 流水线更聪明。
def main():
"""全局兜底:任何未捕获异常都打 traceback + 明确退码。"""
try:
args = build_parser().parse_args()
code = asyncio.run(run(args))
sys.exit(code)
except KeyboardInterrupt:
print("\n[info] interrupted by user (Ctrl+C)", file=sys.stderr)
sys.exit(130)
except SystemExit:
raise
except BaseException as e:
import traceback
print(f"\n!!! UNEXPECTED ERROR: {type(e).__name__}: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
为什么:
asyncio.run 内部的异常会以"非 0 但无信息"退出 —— PowerShell 上会显示成 EXIT=-1KeyboardInterrupt 退 130 是 Unix 约定,便于 shell 脚本检测调用外部 LLM API 失败时,完整打印服务端 body(不只 exception message):
try:
response = await llm_call(...)
except Exception as e:
print(f"!!! LLM CALL FAILED: {type(e).__name__}: {e}", file=sys.stderr)
# httpx HTTPStatusError 等会带 response.text
for attr in ("response", "body"):
obj = getattr(e, attr, None)
if obj is not None:
try:
text = obj.text if hasattr(obj, "text") else str(obj)
print(f"!!! server body:", file=sys.stderr)
print(text[:4000], file=sys.stderr)
except Exception:
pass
import traceback; traceback.print_exc(file=sys.stderr)
return 1
为什么:服务端的 400 body 通常包含具体的拒绝原因("context exceeds 1M tokens" / "API key invalid" / "model not available"),比 Python 的 HTTPStatusError: 400 Bad Request 信息量大 10 倍。
任何会运行超过 5 秒的任务都应该在 stderr 流式打进度。 agent / 多步骤工具尤其重要 —— 否则 5 分钟没动静像挂了。
for tool_use in agent.tool_calls:
print(f"[agent tool_use] {tool_use.name}({json.dumps(tool_use.input)[:200]})",
file=sys.stderr)
# 完成时打统计
print(f"[info] done: turns={n} duration={ms}ms in={i_tok} out={o_tok}",
file=sys.stderr)
为什么:可观察性 —— 用户能看到 agent 在做什么、卡在哪、走偏没。
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="...",
formatter_class=argparse.RawDescriptionHelpFormatter, # 让 \n 在 help 里生效
)
p.add_argument("--mode", choices=["fast", "slow", "smart"], default="smart",
help="运行模式") # 使用 choices 会在前端自动渲染为下拉单选框
p.add_argument("--prompt", help="...")
p.add_argument("--prompt-file", help="输入提示词文件")
p.add_argument("--file", action="append", # 多次传同一参数 → 列表
help="要处理的文档文件(可多次传)")
p.add_argument("--output", help="相对 outputs/ 的路径")
p.add_argument("--stdout", action="store_true", help="强制走 stdout")
p.add_argument("--show-prompt", action="store_true", help="debug 用")
return p
前端解析与 UI 生成规则(重要!):
file / path,或者它的 help 提示语中必须包含 文件 两字(例如 --data 的 help 设为 "输入的数据文件" 也能生效)。action="append"。前端会为每个文件生成一个 Tag,并发送多次相同的参数名。action="store_true" 会被前端自动渲染为复选框。choices=["a", "b"] 的参数,前端会自动渲染为下拉选择框。default 且 required=True,前端会打上红色红星。help 字段也会直接作为输入框的 placeholder 提示。_find_project_root() 探测项目根(不硬编码 parent.parent.parent)sys.path.insert(0, str(PROJECT_ROOT)) + load_dotenv(PROJECT_ROOT / ".env")PROJECT_ROOT / SCRIPT_DIR / OUTPUTS_DIRread_text / write_text 显式 encoding="utf-8"outputs/result_<tag>_<ts>.txt--output 限定在 outputs/ 沙盒内OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)--prompt / --prompt-file / --prompt -(stdin)--file + action="append"--stdout flag 保留管道场景help=...main() 全局 try/except 兜底(包括 KeyboardInterrupt 和 BaseException)[info] mode=... model=... ...examples/process_pipeline/scratch/<tool_name>/ 下建子目录| 错误做法 | 为什么不好 | 正确做法 |
|---|---|---|
PROJECT_ROOT = Path(__file__).parent.parent.parent |
脚本一挪位置就崩 | _find_project_root() |
os.chdir(...) 改全局 cwd |
影响其它子进程/线程 | 用 cwd= 参数传给子进程,本进程不改 |
print(content) 默认输出 |
用户关 terminal 就丢了 | 默认写文件,--stdout 显式打开 |
中文文本 open(path, 'r') |
Windows 用 cp1252 编码读 → 乱码 | 显式 encoding="utf-8" |
except Exception: pass |
静默失败 → 调试地狱 | 至少 traceback.print_exc() |
--max-tokens 4000 写死小值 |
限制了模型能力 | 默认取 model 上限,用户想小再 --max-tokens 100 |
| 客户端做输入大小预检 | 经常过度防御 → 拒绝合法请求 | 信任服务端报错,把错误透传 |
if mode == "X": ...; elif "Y": ...; elif "Z": ... 长链 |
加新 mode 要改多处 | MODES = {"X": fn_x, ...}; MODES[mode]() |
run_prompt/run_prompt.py —— 本文档的所有模式都能在这个脚本里找到具体实现。
读它比读这个文档更直接。