ai 编写的 python react agent
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— 调用 LLMlxml>=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 文件按以下顺序自动查找,找到第一个就用:
--config / -c显式指定的路径./xml_chat.ini~/.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 URLapi_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-4opython 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 下是否保留网络
两层防护:
- 文件工具路径 jail(始终生效,与
enabled无关):read_file/write_file/edit_file/list_dir/grep/glob的路径被解析到root内,../、绝对路径越界直接报[沙箱拦截]。 - shell 的 bwrap 隔离(
enabled=true时):系统目录只读挂载、仅root可写、/tmp用 tmpfs、默认--unshare-net断网、cwd设为root。
backend=auto 时会先用一条最小命令实测 bwrap 能否运行(有些内核禁用了 unprivileged user namespace,bwrap 装了也跑不起来);实测失败则警告后降级为本机直接执行。--no-sandbox 或 XML_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 |
免确认(只读操作,比如 cat、head、ls) |
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. 安全
确认机制分三层:
risk=read— 直接执行,免确认risk=write/shell— 执行前 y/N 确认一次- 危险模式命中 — 高亮红框展示完整命令 + 触发原因,多一次
仍要执行?确认(默认 N)
内置危险黑名单(正则匹配最终渲染的命令字符串):
:(){ :|:& };:及其变体(fork bomb)rm -rf/rm -fr类(任意路径)mkfs、dd of=/dev/sdX、>/dev/sdX(磁盘写操作)chmod 777/chmod -R 777curl | sh/wget | sh(远程管道)shutdown/reboot/poweroff/halteval、sudo
示例危险拦截输出:
============================================================
[危险!] 工具 '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 风格的开源模型等),脚本会:
- 抽取 think 块到独立的
<think>XML 节点,放在对应<assistant>之前(这样阅读 XML 时,推理过程在前、答案在后,更自然) - 清洗后的 assistant 内容(只剩"实际答案")作为
<assistant>节点的 text - 构造新请求时跳过所有
<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传递 (GLMhttps://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_id 是 XML attribute,里面的 JSON 字符串会按 XML 规则做 attribute 转义(< > " 等),但读回时脚本会自动反解,用户无需关心。
故障排查
未找到 openai SDK→pip 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+)。