终端交互与流式输出

前面几章的示例都是"一次性拿完整回答"。放到真实终端里体验天差地别:等 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.contentflush=True;加工具日志让过程可见,用 ANSI 颜色增强可读性(isatty 保护),再用两段式 KeyboardInterrupt 把"取消本轮"和"退出程序"分开,终端体验就完整了。

笔记加载中…