案例详解:利用免费模型Token和免费服务器,将git历史自动转化为用户友好的发布说明,完整踩坑记录。
当前的 AI 工具浪潮正推动团队追求更大的 Agent、更大的上下文窗口、以及更高的月度账单。与之相反的约束反而催生更诚实的设计。这个案例研究将完整呈现一个小项目——构建在免费模型配额和免费服务器之上:一个将原始 git 历史记录转化为用户可读发布说明的 bot,从后台运行到经验总结,全程零成本。
这个项目起源于一个常见的烦恼。一个库每周发布一次,每次发布都需要人工阅读四五十条 commit 信息,再将其翻译成用户能看懂的内容。这些信息技术性强、风格不一,有时还惨不忍睹。用 LLM 自动化这个翻译过程是显而易见的解决方案,但预算恰好是零:没有付费 API 额度、没有付费服务器、对重复账单零容忍。
目标有三个部分。根据两个 tag 之间的 git 历史生成简洁、面向用户的发布说明。定时无人值守运行。零成本。在设计过程中出现了第四个约束:任务必须能够在免费模型 tier 失败时继续产出输出,因为一个依赖速率限制的发布流程,是一个会在周二挂掉的发布流程。
项目使用了 MonkeyCode 的免费模型访问及其免费服务器托管选项。声明:本文是 MonkeyCode 产品推广的一部分。撰写本文时,免费版包含 1000 万 token 额度,但配额和条款会变动,任何团队在依赖这些具体数字之前应先核实。上述架构不依赖这些具体数字;它可以对接任何 OpenAI 兼容的端点和任何可以运行 cron 的免费服务器。
实现是一个单一的 Python 文件,负三个职责:收集 commit、分块总结、写入 notes 文件。收集步骤在两个 ref 之间运行 git log,只保留每条 commit 的 subject 行。总结步骤将 25 条 commit 为一块发送给 OpenAI 兼容的 chat 端点,请求一个分类要点的 JSON 数组,并对响应进行校验。写入步骤将结果渲染为 Markdown 文件。唯一的依赖是 openai 和 tiktoken 两个包。
#!/usr/bin/env python3
"""Generate release notes from git history using a free-tier LLM.
Usage:
python release_notes.py <from_ref> <to_ref> [--out RELEASE_NOTES.md]
Environment:
LLM_BASE_URL provider endpoint (OpenAI-compatible)
LLM_API_KEY provider key
LLM_MODEL model name from the provider dashboard
"""
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from openai import OpenAI
CHUNK_SIZE = int(os.getenv("COMMIT_CHUNK_SIZE", "25"))
MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "400"))
MAX_RETRIES = 3
SYSTEM_PROMPT = (
"You turn raw git commit subjects into concise user-facing release notes. "
"Return a JSON object with one key, 'items', an array of strings. "
"Each string must start with a category: Feature, Fix, Docs, or Chore. "
"Do not invent details that are not in the commits."
)
def git_log(from_ref: str, to_ref: str) -> list[str]:
result = subprocess.run(
["git", "log", "--oneline", f"{from_ref}..{to_ref}"],
capture_output=True,
text=True,
check=True,
)
return [line.split(" ", 1)[1] for line in result.stdout.splitlines() if line.strip()]
def chunks(items: list[str], size: int):
for i in range(0, len(items), size):
yield items[i : i + size]
def summarize_chunk(client: OpenAI, commit_chunk: list[str]) -> list[str]:
response = client.chat.completions.create(
model=os.environ["LLM_MODEL"],
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "\n".join(f"- {c}" for c in commit_chunk)},
],
temperature=0.2,
max_tokens=MAX_TOKENS,
response_format={"type": "json_object"},
)
payload = json.loads(response.choices[0].message.content)
return payload["items"]
def fallback_summarize(commit_chunk: list[str]) -> list[str]:
categories = {"feat": "Feature", "fix": "Fix", "docs": "Docs", "refactor": "Chore"}
grouped: dict[str, list[str]] = {}
for commit in commit_chunk:
prefix = commit.split(":", 1)[0].lower()
category = categories.get(prefix, "Chore")
grouped.setdefault(category, []).append(commit)
return [f"{category}: {commit}" for category, commits in grouped.items() for commit in commits]
def with_retry(client: OpenAI, commit_chunk: list[str]) -> list[str]:
size = len(commit_chunk)
for attempt in range(MAX_RETRIES):
try:
return summarize_chunk(client, commit_chunk[:size])
except Exception:
if attempt == MAX_RETRIES - 1:
return fallback_summarize(commit_chunk)
size = max(1, size // 2)
time.sleep(2**attempt)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("from_ref")
parser.add_argument("to_ref")
parser.add_argument("--out", default="RELEASE_NOTES.md")
args = parser.parse_args()
commits = git_log(args.from_ref, args.to_ref)
if not commits:
Path(args.out).write_text("No user-facing changes in this release.\n", encoding="utf-8")
return 0
client = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])
notes: list[str] = []
for chunk in chunks(commits, CHUNK_SIZE):
notes.extend(with_retry(client, chunk))
body = "\n".join(f"- {note}" for note in notes)
Path(args.out).write_text(f"## Release notes\n\n{body}\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())
三个设计决策比代码本身更重要。第一,chunk 大小让每次请求都远低于模型的上下文限制,因此大的发布窗口被拆解为许多小请求而非一个脆弱的大请求。第二,重试循环在每次失败时将 chunk 大小减半,用同一个机制处理速率限制和上下文溢出。第三,fallback 总结器是确定性的:它按照 conventional-commit 前缀对 commit 分组,因此即使模型从不响应,任务也始终能产出文件。如果模型不支持结构化 JSON 输出,校验步骤会拒绝响应并由 fallback 接管,这让 pipeline 对其依赖保持诚实。
部署刻意做得平庸。免费服务器运行一个每周 cron 作业,执行脚本在两个最新 tag 之间生成文件,并将结果文件提交回仓库。
0 9 * * 1 cd /opt/release-notes && /usr/bin/python3 release_notes.py v1.2.0 v1.3.0 --out RELEASE_NOTES.md
漏掉一次运行是无害的,因为脚本是幂等的,git 范围可以重放。文件是原子写入的,因此不会有部分写入被发布出去。
有趣的结果是各种失败模式,因为那才是免费 tier 揭示其真实个性的地方。下表将每种场景映射到脚本实现的行为。
脚本格式化的输出示例如下(仅作说明):
## Release notes
- Feature: Added exponential backoff for rate-limited LLM calls
- Fix: Corrected chunk-size overflow on large release windows
- Docs: Documented the LLM_BASE_URL environment variable
Token 计费是大多数团队跳过的部分。25 条 commit 的 chunk 的 prompt 大约花费 1200 个输入 token,响应大约 300 个,但这些数字会随 commit 长度而变化。诚实的测量方式是使用 tiktoken,而不是猜测:
from tiktoken import encoding_for_model
def estimate_tokens(text: str) -> int:
return len(encoding_for_model("gpt-4o").encode(text))
以 1000 万 token 的额度,即使悲观的每个 chunk 2000 token 也支持数千个 chunk。算数本身不重要,重要的是习惯:测量 prompt,乘以预期运行次数,并将预期消耗保持在额度的百分之十以下,这样重试永远不会在周期中途耗尽预算。
三个经验被迁移到其他项目中。第一,fallback 路径才是真正的产品;LLM 是构建在其之上的增强。第二,免费 tier 是突发预算,而非基准线,因此日程必须考虑偶发的 429 和空响应。第三,免费服务器非常适合托管每周批处理作业,而不适合托管对延迟敏感的端点——这也是设计将所有重活都放在请求路径之外的原因。
这个方案不适合以下几类团队。任何处理客户数据的团队都不应将其指向数据保留政策不明的免费 tier。任何有面向用户的延迟敏感端点的团队都不应在免费服务器上托管它。任何需要保证 SLA 的团队都应该付费购买。这个 bot 是一个用于内部、低频、非关键自动化的工具,它对自己是什么有清醒的认知。
完整脚本已在上面;fork 到每周 cron 作业需要一个下午。项目很小,失败模式都有界限,下一次发布说明会自动写出来。MonkeyCode 的免费 tier 是获取 token 和服务器的一种方式,但脚本可以对接任何 OpenAI 兼容的端点——这才是重点:让成本归零的是设计,而非提供商。