projects/repobot/v1/repobot.py
124 行 · 4.6 KBコードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。
"""RepoBot v1:httpx 答疑助手的第一版。
命令行里多轮对话,流式输出,出错自动重试,每轮显示词元和花费。
这一版不查任何资料,完全靠模型自己的知识回答。
python repobot.py 开始对话,输入空行退出
python repobot.py --think 开启思考模式
"""
import os
import random
import sys
import time
import openai
from openai import OpenAI
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
THINKING = "--think" in sys.argv
MAX_MESSAGES = 20 # 历史最多保留 20 条消息(10 轮问答)
# 美元 / 每一百万词元,高峰价,截至 2026 年 9 月。用之前去 DeepSeek 的价格页面核对
PRICES = {"deepseek-flash": (0.006, 0.30, 1.20), "deepseek-v4-pro": (0.044, 1.32, 3.96)}
SYSTEM = """你是 RepoBot,Python HTTP 客户端库 httpx 的答疑助手。
- 只回答和 httpx 有关的问题,包括它的用法、原理、报错排查,以及和 requests 等库的比较。
- 和 httpx 无关的问题,礼貌地说明你只负责 httpx,不要回答。
- 回答要简洁,能用代码说明的就给代码。
- 不确定的地方要明确说"我不确定",不要编造版本号、参数名或者更新日志。"""
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
timeout=60,
max_retries=0, # 重试由下面的 open_stream 负责
)
RETRYABLE = (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.InternalServerError)
def cost_usd(usage):
if MODEL not in PRICES:
return 0.0
hit_price, miss_price, out_price = PRICES[MODEL]
hit = getattr(usage, "prompt_cache_hit_tokens", 0) or 0
miss = usage.prompt_tokens - hit
return (hit * hit_price + miss * miss_price + usage.completion_tokens * out_price) / 1_000_000
def open_stream(messages, max_attempts=4):
"""发起流式请求。连接阶段出错会自动重试;开始输出之后再出错,就不重试了。"""
for attempt in range(1, max_attempts + 1):
try:
return client.chat.completions.create(
model=MODEL,
messages=messages,
stream=True,
stream_options={"include_usage": True},
max_tokens=4000,
extra_body={"thinking": {"type": "enabled" if THINKING else "disabled"}},
)
except RETRYABLE as e:
if attempt == max_attempts:
raise
wait = 2 ** (attempt - 1) + random.random()
print(f"\n[{type(e).__name__},{wait:.1f} 秒后重试]", file=sys.stderr)
time.sleep(wait)
def answer(messages):
"""流式打印回答,返回完整的回答文本和 usage。"""
stream = open_stream(messages)
parts, usage, finish = [], None, None
for chunk in stream:
if chunk.usage:
usage = chunk.usage
if not chunk.choices:
continue
choice = chunk.choices[0]
if choice.delta.content:
parts.append(choice.delta.content)
print(choice.delta.content, end="", flush=True)
if choice.finish_reason:
finish = choice.finish_reason
print()
if finish == "length":
print("[回答太长,被截断了]")
return "".join(parts), usage
def main():
history = []
total = 0.0
interactive = sys.stdin.isatty()
print(f"RepoBot v1({MODEL}{',思考模式' if THINKING else ''})。问我 httpx 的问题,输入空行退出。")
while True:
try:
question = input("\n你:" if interactive else "").strip()
except EOFError:
break
if not question:
break
if not interactive:
print(f"\n你:{question}")
messages = [{"role": "system", "content": SYSTEM}] + history + [{"role": "user", "content": question}]
print("RepoBot:", end="", flush=True)
try:
text, usage = answer(messages)
except openai.APIError as e:
print(f"\n[出错了:{type(e).__name__},这一轮作废,可以再问一次]")
continue
cost = cost_usd(usage)
total += cost
print(f"[输入 {usage.prompt_tokens}(缓存命中 {getattr(usage, 'prompt_cache_hit_tokens', 0) or 0}),"
f"输出 {usage.completion_tokens},本轮 {cost:.5f} 美元,累计 {total:.5f} 美元]")
history += [{"role": "user", "content": question}, {"role": "assistant", "content": text}]
history = history[-MAX_MESSAGES:]
if __name__ == "__main__":
main()