pyxchat:基于 Python 与 OpenAI 兼容 API 的命令行多轮对话项目

ai 编写的 python react agent

分支1Tags0
当前项目代码仓暂无内容

pyxchat (xml_chat 项目原用名)

一个 Python 3.9+ 命令行脚本,用 XML 文件管理多轮对话,通过 OpenAI 兼容 API 调用 LLM。

特性

  • XML 即历史:<chat><system/><user/><assistant/>…</chat>,人类可读、可手改
  • 持久化:每次用户输入和模型回复都实时原子写入 XML 文件,中途崩溃不损坏
  • OpenAI 兼容:支持任意 OpenAI 兼容服务(OpenAI、Azure、DeepSeek、Moonshot、Ollama 兼容模式等)
  • 流式输出:默认开启,关闭用 --no-stream 或环境变量 NO_STREAM=1
  • 内置工具 + 沙箱:read_file/write_file/edit_file/list_dir/grep/glob/shell/get_time 开箱即用,文件工具始终限制在沙箱 root 内,shell 可用 bwrap 隔离
  • 斜杠命令:/clear /show /system /save /exit /help

uv 安装

uv add -r requirements.txt
uv pip install -e .

运行程序

uv run xchat -h

运行结果示例

usage: xchat [-h] [--xml XML] [--provider PROVIDER] [--config CONFIG] [--model MODEL] [--base-url BASE_URL] [--api-key API_KEY] [--system SYSTEM]
             [--no-stream] [--no-auto-reply] [--list-providers] [--init-config] [--wizard] [--no-wizard] [--yes] [--no-sandbox] [-w DIR] [-i]
             [--no-tokens] [-a] [--tools] [-t TOOLS] [-c FILE] [-m TEXT]

用 XML 管理多轮对话的 OpenAI 兼容 LLM 客户端

options:
  -h, --help            show this help message and exit
  --xml XML, -x XML     会话 XML 文件路径(默认: chat.xml)
  --provider PROVIDER, -p PROVIDER
                        使用 ini 中 [name] 段配置(可由环境变量 XML_CHAT_PROVIDER 覆盖)
  --config CONFIG       ini 配置文件路径(默认查找 ./xml_chat.ini 或 ~/.xml_chat.ini)
  --model MODEL         模型名(可由 provider.models[0] / OPENAI_MODEL 覆盖)
  --base-url BASE_URL   API base URL(覆盖 provider 配置)
  --api-key API_KEY     API key(覆盖 provider 配置)
  --system SYSTEM       当 XML 不存在或没有 system 节点时使用的初始 system prompt
  --no-stream           关闭流式输出
  --no-auto-reply       关闭自动触发:即使 XML 末尾是 <user> 也不自动调 LLM
  --list-providers      列出 ini 中所有 provider 配置并退出
  --init-config         把示例 ini 模板打到 stdout(可用 > xml_chat.ini 重定向)并退出
  --wizard              强制启动配置向导(即使 XML 已存在也重新生成)
  --no-wizard           XML 不存在时跳过自动向导,直接创建空 XML
  --yes, -y             工具执行前不再 y/N 确认(危险!)
  --no-sandbox          禁用沙箱(即使 ini 的 [sandbox] enabled=true)
  -w DIR, --workdir DIR
                        把 DIR 写入 XML <chat> 的 sandbox 属性,作为沙箱 root / 工作目录(优先于 ini [sandbox] 的 root,默认当前目录)
  -i, --interactive     进入交互式 >>> REPL(默认只处理 XML 末尾的 <user> 后退出)
  --no-tokens           关闭每轮 token 统计显示(默认开启)
  -a, --agent           把全部内置工具以 <tool name="..."/> 声明写入 XML 后退出(不进入对话)
  --tools               以逗号分隔列出全部内置工具名并退出
  -t TOOLS, --tool TOOLS
                        把指定工具加入会话(逗号/空格分隔,如 -t shell,read_file),会以 <tool name="..."/> 声明写入 XML(已声明的跳过)
  -c FILE, --clone FILE
                        从指定 XML 提取 system、工具声明、sandbox 属性和 meta,创建新的 --xml 文件后退出(不进入对话)
  -m TEXT, --continue-message TEXT
                        执行一次对话后退出(非交互模式),不进入 >>> REPL

pip 安装

pip install -r requirements.txt

依赖:

  • openai>=1.0.0 — 调用 LLM
  • lxml>=5.0 — XML 读写(用 CDATA 包裹内容,人眼更可读;自动处理 ]]> 边界)
  • prompt_toolkit>=3.0 — 多行编辑(Enter 提交、Ctrl+J 换行、Esc+Enter 备用提交、跨平台、历史持久化、异步友好)
    • 未装时降级到 readline(Unix 自带)/ pyreadline3(Windows)单行模式

快速开始

# 1) 生成 ini 配置模板
python chat.py --init-config > xml_chat.ini
# 编辑 xml_chat.ini,填入你的 API key 或者用 ${ENV_VAR} 引用环境变量

# 2) 启动交互模式(REPL,会一直等输入)
python chat.py --provider openai --system "你是一个简洁的中文助理"

# 3) 一次性模式:执行一次对话后退出(适合脚本/管道)
python chat.py -c "Python 是什么?" --provider openai

# 4) 切换到 deepseek
python chat.py --provider deepseek --xml deepseek-chat.xml

# 5) 列出当前所有 provider
python chat.py --list-providers

如果你不想用 ini,仍然可以直接用环境变量:

export OPENAI_API_KEY="sk-..."
export OPENAI_BASE_URL="https://api.openai.com/v1"   # 可选
export OPENAI_MODEL="gpt-4o-mini"                     # 可选
python chat.py --system "你是一个简洁的中文助理"

配置文件

ini 文件按以下顺序自动查找,找到第一个就用:

  1. --config / -c 显式指定的路径
  2. ./xml_chat.ini
  3. ~/.xml_chat.ini

也可以用环境变量 XML_CHAT_PROVIDER 指定默认 provider(等价于每次都加 --provider)。

格式

; 分号开头是注释
[default]                              ; 不传 --provider 时默认用这个
base_url      = https://api.openai.com/v1
api_key       = ${OPENAI_API_KEY}      ; 写死也行,比如 sk-xxxx
models        = gpt-4o-mini, gpt-4o, gpt-3.5-turbo
default_model = gpt-4o-mini            ; 不传 --model 时用这个;留空则取 models 第一个

[deepseek]
base_url      = https://api.deepseek.com/v1
api_key       =
models        = deepseek-chat, deepseek-reasoner
default_model = deepseek-chat

[ollama]
base_url      = http://localhost:11434/v1
api_key       = ollama
models        = llama3, qwen2
default_model = llama3

支持的 key:

  • base_url:OpenAI 兼容的 API base URL
  • api_key:API key;值里可以用 ${ENV_VAR} 引用环境变量,缺失时保持原样
  • models:逗号分隔的可用模型列表(--list-providers 展示用)
  • default_model:不传 --model 时用的默认模型;留空则回退到 models 第一个
  • default_model 允许不在 models 列表里(支持未列出的实验性模型)

优先级(从高到低)

provider 选择(没传 --provider 时):

情况 行为
--provider <name> / XML_CHAT_PROVIDER 用 ini 中对应段
ini 中有名为 default 的段 用 default 段
ini 中只有 1 个 provider 自动用它(免去 --provider 参数)
ini 中多个 provider 都没标 default 报错,提示加 [default] 段或显式 --provider
ini 不存在 走环境变量,不选 provider

同字段取值(从选中的 provider 出发):

来源 行为
命令行 --api-key / --base-url / --model 完全覆盖 provider 的对应字段
provider 的 base_url / api_key / models[0] provider 提供默认值
环境变量 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL provider 没值时兜底

典型场景:

  • python chat.py → 啥都没传,自动用 ~/.xml_chat.ini 里的 default
  • python chat.py --model gpt-4o → 还是用 default,但 model 换成 gpt-4o
  • python chat.py --provider deepseek → 切到 deepseek,model 用其 models[0]
  • python chat.py --provider deepseek --model deepseek-reasoner → 显式选 model

命令行参数

参数 默认 说明
--xml chat.xml 会话 XML 文件路径
--provider / -p XML_CHAT_PROVIDER 或 ini 的 default 选 ini 中的 provider 段
--config ./xml_chat.ini~/.xml_chat.ini ini 配置文件路径
--model provider.default_model / models[0] / OPENAI_MODEL 模型名
--base-url OPENAI_BASE_URL API base URL(覆盖 provider)
--api-key OPENAI_API_KEY API key(覆盖 provider)
--system XML 首次创建且无 system 节点时,注入 system prompt
--no-stream 关闭流式 关闭流式输出
--no-auto-reply 自动触发开启 关闭自动触发:即使 XML 末位是 <user> 也不自动调 LLM
--list-providers 列出 ini 中所有 provider / tool 后退出
--init-config 输出 ini 模板到 stdout 后退出
--wizard 强制启动配置向导(重生成 XML)
--no-wizard 自动向导开启 XML 不存在时跳过自动向导
--yes / -y 关闭 工具执行前不再 y/N 确认(危险!)
--no-sandbox 沙箱按 ini 配置 禁用沙箱(即使 ini 的 [sandbox] enabled=true),等价 XML_CHAT_NO_SANDBOX=1
-c / --continue-content 交互 执行一次对话后退出(非交互模式,适合脚本)

运行时命令

命令 说明
/exit 退出
/clear 清空所有历史(需二次确认,保留 tool 声明,同时清零 token 计数)
/show pretty-print 当前 XML
/system <text> 设置或替换 system prompt
/save [path] 把当前 XML 以 pretty 形式另存
/tokens 查看累计 token 用量(in / out / cache / turns)
/help 显示帮助

Spinner + Token 统计

Spinner:等待 LLM 响应时,后台线程用 \|/-\ 旋转提示,响应到达后自动擦除。流式模式下在第一个 chunk 到达时停,避免和流式输出打架。

Token 统计:每次 API 响应带的 usage 字段被自动累加,本轮结束后打印一行:

[tokens] 本轮 1 步  in=100  out=30  cache=50  total turns=1

字段含义:

  • in — 本次请求的 prompt token 数
  • out — 本次请求的 completion token 数
  • cache — prompt 中被 prompt cache 命中的 token(OpenAI / DeepSeek 等支持)
  • turns — 累计 API 调用次数(ReAct 多步工具调用算多次)

大数字自动用 K / M / B 缩位:

范围 格式 例子
< 1,000 原样 in=856
1,000–9,999 X.XK in=1.2K
10,000–999,999 XK in=50K
1,000,000–9,999,999 X.XM in=1.6M
≥ 10,000,000 XM in=85M
≥ 1,000,000,000 X.XB in=2.0B

/tokens 看累计;--no-tokens 关闭显示;/clear 会同时清零计数。

工具调用 (ReAct 模式)

轻量版工具系统:内置工具开箱即用,ini 里可定义自定义工具(同名覆盖内置),XML 里只声明用哪些,实际调用时只把子集传给 LLM

0. 内置工具

脚本自带 8 个常用工具(Python 实现,不走 shell 模板),无需在 ini 中定义,XML 里声明即可用:

工具 参数 risk 说明
read_file path read 读文件,限沙箱 root 内,10KB 截断
write_file path, content write 写/覆盖文件,限 root 内,自动建父目录
edit_file path, old, new write 替换文件中唯一出现的一段文本(多次出现会报错,防误改)
list_dir path(可选) read 列目录,目录带 / 后缀
grep pattern, path(可选) read 递归正则搜索,跳过 .git/.venv 等,上限 100 条
glob pattern read 按 glob 找文件,上限 200 条
shell command shell 执行 shell 命令(沙箱 enabled 时走 bwrap)
get_time read 当前时间

在 ini 中定义同名的 [tools.read_file] 等可以覆盖对应内置工具。

沙箱

ini 中加 [sandbox] 段:

[sandbox]
enabled = false      ; true 时 shell 命令用 bwrap 隔离
backend = auto       ; auto(有 bwrap 就用)| bwrap(强制,不可用则拒绝执行)| none
root    = .          ; 文件工具 / shell 的工作目录,默认当前目录
network = false      ; bwrap 下是否保留网络

两层防护:

  1. 文件工具路径 jail(始终生效,与 enabled 无关):read_file/write_file/edit_file/list_dir/grep/glob 的路径被解析到 root 内,../、绝对路径越界直接报 [沙箱拦截]
  2. shell 的 bwrap 隔离(enabled=true 时):系统目录只读挂载、仅 root 可写、/tmp 用 tmpfs、默认 --unshare-net 断网、cwd 设为 root

backend=auto 时会先用一条最小命令实测 bwrap 能否运行(有些内核禁用了 unprivileged user namespace,bwrap 装了也跑不起来);实测失败则警告后降级为本机直接执行。--no-sandboxXML_CHAT_NO_SANDBOX=1 整体关闭(bwrap 部分;文件 jail 仍生效)。

1. ini 中定义工具

[tools.get_weather]
description = 查询某城市天气
command     = curl -s "https://wttr.in/{city}?format=3"
params      = city
risk        = shell              ; 可选,默认 shell

[tools.read_file]
description = 读取文件内容,参数 path
command     = cat {path}
params      = path
risk        = read               ; 读类免确认

command 是 shell 模板,{param} 会被替换为 LLM 传入的参数(自动 shlex.quote 防注入)。30 秒超时,10KB 输出截断。

risk 字段(可选,默认 shell):

行为
read 免确认(只读操作,比如 catheadls)
write 确认一次(写文件、改状态)
shell 确认一次,且命中危险黑名单时多一次高亮确认

2. XML 中声明使用哪些

<chat>
  <system>你可以使用工具。</system>
  <tool name="get_weather"/>
  <tool name="get_time"/>
  <user>北京现在几点?天气如何?</user>
</chat>

<tool name="..."/> 是声明节点(不会被发给 LLM),只是告诉脚本"我这场对话要用这些工具"。真实调 API 时,只把声明的子集作为 tools=[...] 传过去。

3. ReAct 循环

启动后,LLM 在每轮可以调多个工具 → 我们执行 → 把结果用 role="tool" 回传 → LLM 再决策 → 直到它给不含 tool_calls 的纯文本,就是 Final Answer。最大 10 步防死循环。

XML 完整记录 ReAct 过程:

<user><![CDATA[
北京现在几点?天气如何?
]]></user>
<assistant tool_calls="[{...}]"/>           <!-- 决策:调 get_weather -->
<tool tool_call_id="call_xxx"><![CDATA[
北京 25°C 晴
]]></tool>
<assistant tool_calls="[{...}]"/>           <!-- 再决策:调 get_time -->
<tool tool_call_id="call_yyy"><![CDATA[
2026-08-30 10:00
]]></tool>
<assistant><![CDATA[
北京25度晴,现在10点。
]]></assistant>  <!-- Final Answer -->

4. 安全

确认机制分三层:

  1. risk=read — 直接执行,免确认
  2. risk=write / shell — 执行前 y/N 确认一次
  3. 危险模式命中 — 高亮红框展示完整命令 + 触发原因,多一次 仍要执行? 确认(默认 N)

内置危险黑名单(正则匹配最终渲染的命令字符串):

  • :(){ :|:& };: 及其变体(fork bomb)
  • rm -rf / rm -fr 类(任意路径)
  • mkfsdd of=/dev/sdX>/dev/sdX(磁盘写操作)
  • chmod 777 / chmod -R 777
  • curl | sh / wget | sh(远程管道)
  • shutdown / reboot / poweroff / halt
  • evalsudo

示例危险拦截输出:

============================================================
[危险!] 工具 'cleanup' 触发拦截
  原因: rm -rf
  完整命令: rm -rf /var/log/*
  参数: {'path': '/var/log/*'}
============================================================
  仍要执行? [y/N]:

批量自动化用 --yes / -y / XML_CHAT_YES=1 跳过所有确认(包括危险的二次确认),慎用!

5. 配置向导

XML 不存在时,如果有 ini 且里面定义了工具,会自动进入向导引导你输入 system、选择工具、写首个 user(直接回车可留空)。不想用向导加 --no-wizard;想强制重做加 --wizard

6. 交互模式行编辑(prompt_toolkit)

启动时自动启用 prompt_toolkit(多行编辑),如果没装就降级到 readline。

按键 行为
Enter 提交当前输入(multiline 模式默认是换行,这里覆盖)
Ctrl+J 换行(在 user 内容里插入真换行)
Esc+Enter 备用提交
Ctrl+D 提交(空行时 EOF 退出)
Ctrl+C 立即退出(打印 Bye~)
/ 历史(持久化到 ~/.xml_chat_history)
移动光标
Ctrl+A / Ctrl+E 跳到行首/行尾
Backspace 删除光标前字符

多行内容会原样保留到 XML:按 Ctrl+J 换行,内容里的 \n 会写入 <user> 节点(用 CDATA 包裹),LLM 看到的就是完整的多段文本。

7. 一次性模式(-c / --continue-content)

-c "<内容>" 让脚本执行一次对话后立即退出,不进入 >>> REPL,适合 shell 脚本、管道、定时任务。

# 一次提问,得到回答后退出(exit 0)
python chat.py -c "Python 列表推导式是什么?" 

# 接续已有 XML 继续聊
python chat.py -c "再举几个例子"

# 串到其他工具
python chat.py -c "总结这段代码" | tee log.txt

# 与 auto-reply 配合:XML 末位是 user,会先跑完 auto,再跑 -c
python chat.py -c "下一轮"

行为细节:

  • 不进入 REPL,不需要 stdin
  • 不触发 wizard,XML 不存在则报错退出(exit 1)
  • auto-reply 仍然执行:如果 XML 末位是 <user>,先自动完成那一轮,再执行 -c 的内容
  • 退出码:成功 0,API 错误 1
  • --wizard 互斥,传了会忽略 --wizard

XML 格式说明

根节点 <chat>,子节点必须使用 system / user / assistant 三种 role 之一。

pretty 格式 + CDATA 包裹:脚本始终以 pretty 形式(每节点一行,2 空格缩进)写盘,所有 text 用 <![CDATA[ ... ]]> 包裹——CDATA 内部用换行符把内容夹在中间,而不是挤在一行。目的是方便 cat chat.xml 直接查看,内容一眼可读。

<?xml version="1.0" encoding="UTF-8"?>
<chat>
  <system><![CDATA[
你的系统提示词
]]></system>
  <user><![CDATA[
用户消息
]]></user>
  <assistant><![CDATA[
助手回复
]]></assistant>
  <user><![CDATA[
下一轮用户消息
]]></user>
  <assistant><![CDATA[
下一轮助手回复
]]></assistant>
</chat>

关于多轮续聊

启动时脚本会按出现顺序读取所有子节点,直接转成 OpenAI messages 传给 API。 你可以手工编辑 XML 来增删/修改历史,保存后下次启动脚本就会用新历史继续。 手工编辑时不需要手动加 CDATA 包裹,脚本下次保存时会自动重包(原 CDATA 在 parse 后会丢标记,但内容原样保留)。

关于 块(推理模型)

对于会在 content 里返回 <think>...</think> 块的模型(DeepSeek-R1、某些 o1 风格的开源模型等),脚本会:

  1. 抽取 think 块到独立的 <think> XML 节点,放在对应 <assistant> 之前(这样阅读 XML 时,推理过程在前、答案在后,更自然)
  2. 清洗后的 assistant 内容(只剩"实际答案")作为 <assistant> 节点的 text
  3. 构造新请求时跳过所有 <think> 节点,不污染上下文

这样 XML 既能完整回看每一步的推理过程,又不会让历史 think 反复回传给 LLM 占 token / 影响判断。

对于 GLM 等深度思考 API(thinking: {"type": "enabled"}),思考内容通过独立的 reasoning_content 字段返回(不在 content 里),脚本同样会解析并合并存入 <think> 节点; 流式模式下思考内容以暗色实时打印,正文开始后切换为青色。

关于 节点(会话级 LLM 参数)

<chat> 的第一个子元素可以是 <meta>,存放要传给 LLM 的采样/思考参数(不进 messages):

<chat>
  <meta>
    <thinking type="enabled"/>              <!-- enabled | disabled(GLM 深度思考) -->
    <reasoning_effort value="high"/>        <!-- max | xhigh | high | medium | low | minimal | none -->
    <temperature value="0.7"/>              <!-- 0.0 ~ 2.0 -->
  </meta>
  <system>...</system>
  ...
</chat>
  • temperature 作为标准字段传递;thinking / reasoning_effort 通过 extra_body 传递 (GLM https://open.bigmodel.cn/api/paas/v4/chat/completions 等兼容接口可识别)。
  • 非法值会打印警告并忽略;没有 <meta> 或子元素缺失时不传对应参数。
  • 新建会话(--wizard / 自动创建)会自带 <meta><thinking type="disabled"/></meta>; /clear--from-xml 等操作会保留 <meta> 且保持其为第一个子元素。

<think> 节点的例子:

<user><![CDATA[
北京今天天气怎么样?
]]></user>
<think><![CDATA[
用户问北京天气,我应该用 get_weather 工具查
考虑 wttr.in 这个 API
构造调用...
]]></think>
<assistant><![CDATA[
北京25度,晴。
]]></assistant>
<user><![CDATA[
上海呢?
]]></user>
<think><![CDATA[
...
]]></think>
<assistant><![CDATA[
上海18度,阴。
]]></assistant>

关于自动触发(用 XML 初始化一段对话)

如果你想用 XML 预置一段对话让模型先答一句,只要把 XML 末位写成 <user>,启动脚本时会自动调用一次 LLM 并把 assistant 回复写回 XML。

例如新建 init.xml:

<?xml version="1.0" encoding="UTF-8"?>
<chat>
  <system>你是简洁的中文助理</system>
  <user>用一句话介绍自己</user>
</chat>

然后运行:

python chat.py --xml init.xml
# 启动时自动看到 LLM 的回复,无需手动输入
# 然后进入 >>> 提示符继续对话

如果 XML 末位是 <assistant>(也就是历史已经"完整"),脚本会等你手动输入,不会自动触发。需要强制关闭这个行为,加 --no-auto-reply

关于特殊字符

由于 text 都用 CDATA 包裹,& < > " ' 等 XML 特殊字符在消息内容里不需要任何转义,直接写就行。脚本自动处理 ]]> 边界(若消息内容里出现这个序列,会被 lxml 自动拆分为两个 CDATA 段)。

例外:<assistant tool_calls="..."/><tool tool_call_id="..."/> 这两个节点的 tool_calls / tool_call_idXML attribute,里面的 JSON 字符串会按 XML 规则做 attribute 转义(&lt; &gt; &quot; 等),但读回时脚本会自动反解,用户无需关心。

故障排查

  • 未找到 openai SDKpip install -r requirements.txt
  • 缺少 API key → 设置 OPENAI_API_KEY 或用 --api-key
  • 无法解析 XML → 确认根节点是 <chat>,子节点 role 在 system/user/assistant 之中
  • API 错误导致 user 消息残留 → 脚本会自动回滚该条,下次重启不会带着失败的请求

Python 兼容性

代码仅使用 3.9 及之前可用的语法,from __future__ import annotations 进一步放宽注解写法。 依赖 openai>=1.0(其最低支持 Python 3.7+)。

项目介绍

ai 编写的 python react agent

定制我的领域