终端交互与流式输出
前面几章的示例都是"一次性拿完整回答"。放到真实终端里体验天差地别:等 10 秒黑屏,和字一个个蹦出来,用户耐心完全不同。本章把对话做成终端程序:逐字流式输出、工具调用日志、两行 ANSI 颜色(零依赖)、以及 Ctrl+C 优雅退出。
流式输出:处理 delta
openai SDK v1.x 设 stream=True 后,回答被切成增量块逐个返回,内容在 chunk.choices[0].delta.content:
# agent_demo/stream_cli.py —— 第 11 章
from llm_client import LLMClient # 沿用第 2 章:自动读 .env 的密钥/地址/模型
llm = LLMClient()
def stream_reply(messages) -> str:
"""边生成边打印模型的回答,同时完整收集后返回。"""
resp = llm.chat(messages, stream=True) # stream=True 时返回逐块流对象
answer = []
for chunk in resp:
if not chunk.choices: # 部分服务商中途推 usage 等无内容块
continue
delta = chunk.choices[0].delta
if delta and delta.content:
print(delta.content, end="", flush=True) # flush=True 强制立即上屏
answer.append(delta.content)
print()
return "".join(answer)
两个细节决定成败:end="" 不换行;flush=True 立刻刷出。缺了 flush,终端仍会一段段地"攒着"输出。
颜色:两行 ANSI,零依赖
import sys
def color(text: str, code: int = 33) -> str:
"""ANSI 上色:32 绿 / 33 黄 / 36 青;重定向到文件时自动去色。"""
if not sys.stdout.isatty(): # 不是终端(管道/重定向)就不加控制符
return text
return f"\x1b[{code}m{text}\x1b[0m"
print(color("助手> ", 36) + "你好")
原理就是包一层 \x1b[33m 文字 \x1b[0m,完全不需要 colorama 之类的依赖;判断 isatty() 可避免把控制符写进日志文件。
工具日志:让用户看见智能体在做什么
黑盒等待最劝退。在第 8 章 agent.py 的 run_agent 里执行工具(call_tool)的前后各打一行,用户立刻知道"它在调天气接口、在查数据库":
def log_tool(name: str, args, result: str):
"""接在 call_tool 调用前后:调用前打 name+args,调用后打结果摘要。"""
print(color(f" [tool] {name}({args})", 33))
brief = result[:60] + ("…" if len(result) > 60 else "")
print(color(f" [tool] → {brief}", 36))
把这两行插进 run_agent 调用 call_tool 的前后即可,循环逻辑不用动(agent.py 自带 <- 返回 日志,颜色版可作为增强)。
打字机效果(可选)
想更有"对话感"可以逐字慢打,time.sleep 期间 Ctrl+C 能被立即中断,正好交给下面的异常处理:
import time
def type_out(text: str, cps: float = 30):
"""逐字打印,cps 为每秒字符数。"""
for ch in text:
print(ch, end="", flush=True)
time.sleep(1 / cps)
print()
优雅退出:两段式 KeyboardInterrupt
完整的终端智能体:输入阶段按 Ctrl+C 直接退出;回答生成途中按 Ctrl+C 只取消本轮,撤掉刚提交的提问让用户重说:
def main():
messages = [{"role": "system",
"content": "你是终端里的助手,回答简短,用中文。"}]
print(color("== 流式终端:生成中 Ctrl+C 取消本轮 / 输入框 Ctrl+C 退出 ==", 36))
while True:
try:
text = input(color("\n你> ", 32)).strip()
except KeyboardInterrupt: # 在输入框按 Ctrl+C → 退出
print(color("\n已退出。", 33))
break
if not text:
continue
messages.append({"role": "user", "content": text})
try:
print(color("助手> ", 32), end="")
reply = stream_reply(messages) # 逐字流式打印
messages.append({"role": "assistant", "content": reply})
except KeyboardInterrupt: # 生成途中按 Ctrl+C → 只取消本轮
print(color("\n[已中断本轮回答,请重新输入]", 33))
messages.pop() # 撤掉本轮 user,避免出现连续两条 user
if __name__ == "__main__":
main()
把上面的 color、stream_reply 与 main 合进同一个 stream_cli.py(.env 已按第 2 章配好),python stream_cli.py 即可运行;打字机效果把 stream_reply 里的 print 换成 type_out 即可。
运行效果:
== 流式终端:生成中 Ctrl+C 取消本轮 / 输入框 Ctrl+C 退出 ==
你> 用一句话解释什么是智能体工具调用
助手> 工具调用是让模型在对话中按需触发外部函数(查库、调接口等),
把结果作为新消息继续推理、最终给出回答的过程。
你> (这里按 Ctrl+C)
已退出。
给带工具版本也加流式:把第 8 章 run_agent 里的请求换成 stream=True,逐块打印 content,同时把 delta 里的 tool_calls 片段累积起来(参考第 6/8 章的消息协议)供循环判断,再配合 log_tool 日志,就能同时获得"流式 + 工具可见 + 优雅退出"的完整体验。
小结:流式输出的关键是处理 delta.content 并 flush=True;加工具日志让过程可见,用 ANSI 颜色增强可读性(isatty 保护),再用两段式 KeyboardInterrupt 把"取消本轮"和"退出程序"分开,终端体验就完整了。